feat: forward emails from email conversations (#15871)
This commit is contained in:
@@ -16,6 +16,7 @@ Layout/LineLength:
|
||||
Metrics/ClassLength:
|
||||
Max: 175
|
||||
Exclude:
|
||||
- 'app/builders/messages/message_builder.rb'
|
||||
- 'app/models/message.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
- 'config/initializers/rack_attack.rb'
|
||||
|
||||
@@ -7,7 +7,7 @@ class Messages::MessageBuilder
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
@params = params
|
||||
@private = params[:private] || false
|
||||
@private = ActiveModel::Type::Boolean.new.cast(params[:private]) || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@account = conversation.account
|
||||
@@ -49,9 +49,7 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return if @attachments.blank?
|
||||
|
||||
@attachments.each do |uploaded_attachment|
|
||||
(Array(@attachments) + forwarded_attachments).each do |uploaded_attachment|
|
||||
attachment = @message.attachments.build(
|
||||
account_id: @message.account_id,
|
||||
file: uploaded_attachment
|
||||
@@ -76,7 +74,27 @@ class Messages::MessageBuilder
|
||||
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
|
||||
end
|
||||
|
||||
def forwarded_message_id
|
||||
content_attributes[:forwarded_message_id]
|
||||
end
|
||||
|
||||
def forwarded_attachments
|
||||
return [] if forwarded_message_id.blank?
|
||||
|
||||
forwarded_message = @conversation.messages.find(forwarded_message_id)
|
||||
forwarded_message.attachments.where(id: @params[:forwarded_attachment_ids]).map { |attachment| attachment.file.blob }
|
||||
end
|
||||
|
||||
def validate_forward
|
||||
return if forwarded_message_id.blank?
|
||||
|
||||
raise StandardError, 'Forwarded emails need an email inbox' unless @conversation.inbox.email?
|
||||
raise StandardError, 'Forwarded emails cannot be private' if @private
|
||||
raise StandardError, 'Forwarded emails need a recipient' if process_email_string(@params[:to_emails]).empty?
|
||||
end
|
||||
|
||||
def process_emails
|
||||
validate_forward
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
cc_emails = process_email_string(@params[:cc_emails])
|
||||
|
||||
@@ -20,7 +20,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
|
||||
def destroy
|
||||
ActiveRecord::Base.transaction do
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
|
||||
# keeps the forward marker so replies to a deleted forward stay out of this conversation
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text,
|
||||
content_attributes: message.content_attributes.slice('forwarded_message_id').merge(deleted: true))
|
||||
message.attachments.destroy_all
|
||||
end
|
||||
end
|
||||
@@ -69,21 +71,14 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
next false unless message.failed?
|
||||
|
||||
Messages::StatusUpdateService.new(message, 'sent').perform
|
||||
previous_source_id = message.source_id
|
||||
retry_attributes = { content_attributes: retry_content_attributes }
|
||||
retry_attributes[:source_id] = nil unless @conversation.inbox.api? || @conversation.inbox.web_widget?
|
||||
message.update!(retry_attributes)
|
||||
if retry_attributes.key?(:source_id) && previous_source_id.present?
|
||||
Rails.logger.info "Cleared older source ID #{previous_source_id} for message #{message.id}"
|
||||
end
|
||||
clear_source_id unless @conversation.inbox.api? || @conversation.inbox.web_widget?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def retry_content_attributes
|
||||
return message.content_attributes if message.content_attributes.dig('whatsapp_contact_info', 'type') == 'request'
|
||||
|
||||
{}
|
||||
def clear_source_id
|
||||
Rails.logger.info "Cleared older source ID #{message.source_id} for message #{message.id}" if message.source_id.present?
|
||||
message.update!(source_id: nil)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -13,6 +13,8 @@ export const buildCreatePayload = ({
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
emailHtmlContent,
|
||||
forwardedAttachmentIds,
|
||||
}) => {
|
||||
let payload;
|
||||
if (files && files.length !== 0) {
|
||||
@@ -37,6 +39,12 @@ export const buildCreatePayload = ({
|
||||
if (isVoiceMessage) {
|
||||
payload.append('is_voice_message', true);
|
||||
}
|
||||
if (emailHtmlContent) {
|
||||
payload.append('email_html_content', emailHtmlContent);
|
||||
}
|
||||
forwardedAttachmentIds?.forEach(id => {
|
||||
payload.append('forwarded_attachment_ids[]', id);
|
||||
});
|
||||
} else {
|
||||
payload = {
|
||||
content: message,
|
||||
@@ -47,6 +55,8 @@ export const buildCreatePayload = ({
|
||||
bcc_emails: bccEmails,
|
||||
to_emails: toEmails,
|
||||
template_params: templateParams,
|
||||
email_html_content: emailHtmlContent,
|
||||
forwarded_attachment_ids: forwardedAttachmentIds,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
@@ -69,6 +79,8 @@ class MessageApi extends ApiClient {
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
emailHtmlContent,
|
||||
forwardedAttachmentIds,
|
||||
}) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
@@ -84,6 +96,8 @@ class MessageApi extends ApiClient {
|
||||
toEmails,
|
||||
templateParams,
|
||||
isVoiceMessage,
|
||||
emailHtmlContent,
|
||||
forwardedAttachmentIds,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,5 +107,62 @@ describe('#ConversationAPI', () => {
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('is_voice_message')).toBeNull();
|
||||
});
|
||||
|
||||
it('appends the forwarded email fields to the form payload', () => {
|
||||
const formPayload = buildCreatePayload({
|
||||
message: 'Forwarding this',
|
||||
echoId: 44,
|
||||
isPrivate: false,
|
||||
toEmails: 'vendor@example.com',
|
||||
contentAttributes: { forwarded_message_id: 501 },
|
||||
emailHtmlContent: '<p>Edited body</p>',
|
||||
forwardedAttachmentIds: [7, 8],
|
||||
files: [new Blob(['test-content'], { type: 'application/pdf' })],
|
||||
});
|
||||
|
||||
expect(formPayload.get('to_emails')).toEqual('vendor@example.com');
|
||||
expect(formPayload.get('content_attributes')).toEqual(
|
||||
'{"forwarded_message_id":501}'
|
||||
);
|
||||
expect(formPayload.get('email_html_content')).toEqual(
|
||||
'<p>Edited body</p>'
|
||||
);
|
||||
expect(formPayload.getAll('forwarded_attachment_ids[]')).toEqual([
|
||||
'7',
|
||||
'8',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not append forwarded email fields when they are absent', () => {
|
||||
const formPayload = buildCreatePayload({
|
||||
message: 'regular',
|
||||
echoId: 45,
|
||||
isPrivate: false,
|
||||
files: [new Blob(['test-content'], { type: 'application/pdf' })],
|
||||
});
|
||||
|
||||
expect(formPayload.get('email_html_content')).toBeNull();
|
||||
expect(formPayload.getAll('forwarded_attachment_ids[]')).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes the forwarded email fields in the object payload', () => {
|
||||
expect(
|
||||
buildCreatePayload({
|
||||
message: 'Forwarding this',
|
||||
isPrivate: false,
|
||||
echoId: 46,
|
||||
toEmails: 'vendor@example.com',
|
||||
contentAttributes: { forwarded_message_id: 501 },
|
||||
emailHtmlContent: '<p>Edited body</p>',
|
||||
forwardedAttachmentIds: [7],
|
||||
})
|
||||
).toMatchObject({
|
||||
content: 'Forwarding this',
|
||||
to_emails: 'vendor@example.com',
|
||||
content_attributes: { forwarded_message_id: 501 },
|
||||
email_html_content: '<p>Edited body</p>',
|
||||
forwarded_attachment_ids: [7],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ const props = defineProps({
|
||||
validator: value => ['info', 'error', 'success'].includes(value),
|
||||
},
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
signature: { type: String, default: '' },
|
||||
@@ -103,6 +104,7 @@ watch(
|
||||
:focus-on-mount="focusOnMount"
|
||||
:disabled="disabled"
|
||||
:enable-variables="enableVariables"
|
||||
:variables="variables"
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
:signature="signature"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import RecipientsInput from './RecipientsInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, required: true },
|
||||
@@ -43,19 +43,6 @@ const bccEmailsArray = computed(() =>
|
||||
props.bccEmails ? props.bccEmails.split(',').map(email => email.trim()) : []
|
||||
);
|
||||
|
||||
const contactEmailsList = computed(() => {
|
||||
return props.contacts
|
||||
?.filter(contact => contact.email)
|
||||
.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle updates from TagInput and convert array back to string
|
||||
const handleCcUpdate = value => {
|
||||
ccEmails.value = value.join(',');
|
||||
@@ -83,59 +70,38 @@ const inputClass = computed(() => {
|
||||
:custom-input-class="inputClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8">
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
|
||||
>
|
||||
{{ t(`${i18nPrefix}.CC_LABEL`) }}
|
||||
</label>
|
||||
<div class="flex items-center w-full gap-3 min-h-7">
|
||||
<TagInput
|
||||
:model-value="ccEmailsArray"
|
||||
:placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)"
|
||||
:menu-items="contactEmailsList"
|
||||
:show-dropdown="showCcEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||
@update:model-value="handleCcUpdate"
|
||||
/>
|
||||
<Button
|
||||
:label="t(`${i18nPrefix}.BCC_BUTTON`)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="flex-shrink-0"
|
||||
@click="toggleBccInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBccInput"
|
||||
class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8"
|
||||
<RecipientsInput
|
||||
:model-value="ccEmailsArray"
|
||||
:label="t(`${i18nPrefix}.CC_LABEL`)"
|
||||
:placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)"
|
||||
:contacts="contacts"
|
||||
:show-dropdown="showCcEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||
@update:model-value="handleCcUpdate"
|
||||
>
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
|
||||
>
|
||||
{{ t(`${i18nPrefix}.BCC_LABEL`) }}
|
||||
</label>
|
||||
<TagInput
|
||||
:model-value="bccEmailsArray"
|
||||
:placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)"
|
||||
:menu-items="contactEmailsList"
|
||||
:show-dropdown="showBccEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@input="emit('searchBccEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||
@update:model-value="handleBccUpdate"
|
||||
<Button
|
||||
:label="t(`${i18nPrefix}.BCC_BUTTON`)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="flex-shrink-0"
|
||||
@click="toggleBccInput"
|
||||
/>
|
||||
</div>
|
||||
</RecipientsInput>
|
||||
<RecipientsInput
|
||||
v-if="showBccInput"
|
||||
:model-value="bccEmailsArray"
|
||||
:label="t(`${i18nPrefix}.BCC_LABEL`)"
|
||||
:placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)"
|
||||
:contacts="contacts"
|
||||
:show-dropdown="showBccEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
focus-on-mount
|
||||
@input="emit('searchBccEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||
@update:model-value="handleBccUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -33,6 +33,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
removable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -57,12 +61,14 @@ const targetInboxLabel = computed(() => {
|
||||
</label>
|
||||
<div
|
||||
v-if="targetInbox"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate h-7 min-w-0"
|
||||
:class="removable ? 'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1' : 'px-3'"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ targetInboxLabel }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="removable"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
|
||||
@@ -6,11 +6,14 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: { type: String, default: '' },
|
||||
compact: { type: Boolean, default: false },
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
copilot: { type: Object, default: null },
|
||||
});
|
||||
|
||||
@@ -64,15 +67,20 @@ const executeCopilotAction = (action, data) => {
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
placeholder ||
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="[
|
||||
compact
|
||||
? '[&_.ProseMirror-woot-style]:!min-h-[4rem]'
|
||||
: '[&_.ProseMirror-woot-style]:!min-h-[12rem]',
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
: '',
|
||||
]"
|
||||
enable-variables
|
||||
:variables="variables"
|
||||
enable-captain-tools
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
placeholder: { type: String, default: '' },
|
||||
contacts: { type: Array, default: () => [] },
|
||||
showDropdown: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['input', 'onClickOutside']);
|
||||
|
||||
const modelValue = defineModel({ type: Array, default: () => [] });
|
||||
|
||||
const contactEmailsList = computed(() =>
|
||||
props.contacts
|
||||
.filter(contact => contact.email)
|
||||
.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8">
|
||||
<label class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="flex items-center w-full gap-3 min-h-7">
|
||||
<!-- Chip height plus the tag input's transparent border, so adding a recipient doesn't grow the row -->
|
||||
<TagInput
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:menu-items="contactEmailsList"
|
||||
:show-dropdown="showDropdown"
|
||||
:is-loading="isLoading"
|
||||
:focus-on-mount="focusOnMount"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-[1.875rem]"
|
||||
@input="emit('input', $event)"
|
||||
@on-click-outside="emit('onClickOutside')"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import EmailOptions from '../EmailOptions.vue';
|
||||
import RecipientsInput from '../RecipientsInput.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const contacts = [{ id: 1, name: 'Jane', email: 'jane@example.com' }];
|
||||
|
||||
const mountOptions = (props = {}) =>
|
||||
mount(EmailOptions, {
|
||||
props: {
|
||||
contacts,
|
||||
subject: 'Hello',
|
||||
ccEmails: 'a@example.com, b@example.com',
|
||||
bccEmails: '',
|
||||
...props,
|
||||
},
|
||||
global: { stubs: { InlineInput: true, TagInput: true } },
|
||||
});
|
||||
|
||||
describe('EmailOptions', () => {
|
||||
it('renders the cc recipients row from the comma separated string', () => {
|
||||
const wrapper = mountOptions();
|
||||
const rows = wrapper.findAllComponents(RecipientsInput);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].props()).toMatchObject({
|
||||
modelValue: ['a@example.com', 'b@example.com'],
|
||||
contacts,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits the cc emails back as a comma separated string', async () => {
|
||||
const wrapper = mountOptions();
|
||||
|
||||
await wrapper
|
||||
.findComponent(RecipientsInput)
|
||||
.vm.$emit('update:modelValue', ['c@example.com', 'd@example.com']);
|
||||
|
||||
expect(wrapper.emitted('update:ccEmails')).toEqual([
|
||||
['c@example.com,d@example.com'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards cc search and dropdown events', async () => {
|
||||
const wrapper = mountOptions();
|
||||
const cc = wrapper.findComponent(RecipientsInput);
|
||||
|
||||
await cc.vm.$emit('input', 'ja');
|
||||
await cc.vm.$emit('onClickOutside');
|
||||
|
||||
expect(wrapper.emitted('searchCcEmails')).toEqual([['ja']]);
|
||||
expect(wrapper.emitted('updateDropdown')).toEqual([['cc', false]]);
|
||||
});
|
||||
|
||||
it('reveals the bcc row on demand and wires it up', async () => {
|
||||
const wrapper = mountOptions({ bccEmails: 'x@example.com' });
|
||||
|
||||
await wrapper.findComponent(Button).trigger('click');
|
||||
const rows = wrapper.findAllComponents(RecipientsInput);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1].props()).toMatchObject({
|
||||
modelValue: ['x@example.com'],
|
||||
focusOnMount: true,
|
||||
});
|
||||
|
||||
await rows[1].vm.$emit('update:modelValue', ['y@example.com']);
|
||||
await rows[1].vm.$emit('input', 'y');
|
||||
await rows[1].vm.$emit('onClickOutside');
|
||||
|
||||
expect(wrapper.emitted('update:bccEmails')).toEqual([['y@example.com']]);
|
||||
expect(wrapper.emitted('searchBccEmails')).toEqual([['y']]);
|
||||
expect(wrapper.emitted('updateDropdown')).toEqual([['bcc', false]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import InboxSelector from '../InboxSelector.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
const targetInbox = {
|
||||
name: 'Support',
|
||||
email: 'support@example.com',
|
||||
channelType: INBOX_TYPES.EMAIL,
|
||||
};
|
||||
|
||||
const mountSelector = (props = {}) =>
|
||||
mount(InboxSelector, {
|
||||
props: { targetInbox, showInboxesDropdown: false, ...props },
|
||||
});
|
||||
|
||||
describe('InboxSelector', () => {
|
||||
it('shows the selected inbox with a remove button by default', async () => {
|
||||
const wrapper = mountSelector();
|
||||
|
||||
expect(wrapper.text()).toContain('Support (support@example.com)');
|
||||
await wrapper.findComponent(Button).trigger('click');
|
||||
expect(wrapper.emitted('updateInbox')).toEqual([[null]]);
|
||||
});
|
||||
|
||||
it('hides the remove button when the inbox is not removable', () => {
|
||||
const wrapper = mountSelector({ removable: false });
|
||||
|
||||
expect(wrapper.text()).toContain('Support (support@example.com)');
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { withFullI18n } from 'test-i18n';
|
||||
import MessageEditor from '../MessageEditor.vue';
|
||||
|
||||
const i18n = withFullI18n();
|
||||
|
||||
const EditorStub = {
|
||||
props: ['placeholder', 'modelValue'],
|
||||
template: '<div class="editor-stub" :data-placeholder="placeholder" />',
|
||||
};
|
||||
|
||||
const mountEditor = (props = {}) =>
|
||||
mount(MessageEditor, {
|
||||
props: { modelValue: '', ...props },
|
||||
global: {
|
||||
stubs: { Editor: EditorStub, CopilotEditorSection: true },
|
||||
},
|
||||
});
|
||||
|
||||
describe('MessageEditor', () => {
|
||||
it('uses the compose placeholder and full height by default', () => {
|
||||
const editor = mountEditor().find('.editor-stub');
|
||||
|
||||
expect(editor.attributes('data-placeholder')).toBe(
|
||||
i18n.global.t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
);
|
||||
expect(editor.classes()).toContain(
|
||||
'[&_.ProseMirror-woot-style]:!min-h-[12rem]'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a custom placeholder and a compact height', () => {
|
||||
const editor = mountEditor({
|
||||
placeholder: 'Add a message',
|
||||
compact: true,
|
||||
}).find('.editor-stub');
|
||||
|
||||
expect(editor.attributes('data-placeholder')).toBe('Add a message');
|
||||
expect(editor.classes()).toContain(
|
||||
'[&_.ProseMirror-woot-style]:!min-h-[4rem]'
|
||||
);
|
||||
expect(editor.classes()).not.toContain(
|
||||
'[&_.ProseMirror-woot-style]:!min-h-[12rem]'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import RecipientsInput from '../RecipientsInput.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
const contacts = [
|
||||
{ id: 1, name: 'Jane', email: 'jane@example.com' },
|
||||
{ id: 2, name: 'No Email', email: null },
|
||||
{ id: 3, name: 'John', email: 'john@example.com' },
|
||||
];
|
||||
|
||||
const mountInput = (props = {}, options = {}) =>
|
||||
mount(RecipientsInput, {
|
||||
props: { label: 'To:', ...props },
|
||||
global: { stubs: { TagInput: true } },
|
||||
...options,
|
||||
});
|
||||
|
||||
describe('RecipientsInput', () => {
|
||||
it('renders the label and passes the input props through', () => {
|
||||
const wrapper = mountInput({
|
||||
placeholder: 'Search',
|
||||
showDropdown: true,
|
||||
isLoading: true,
|
||||
focusOnMount: true,
|
||||
modelValue: ['jane@example.com'],
|
||||
});
|
||||
const tagInput = wrapper.findComponent(TagInput);
|
||||
|
||||
expect(wrapper.find('label').text()).toBe('To:');
|
||||
expect(tagInput.props()).toMatchObject({
|
||||
placeholder: 'Search',
|
||||
showDropdown: true,
|
||||
isLoading: true,
|
||||
focusOnMount: true,
|
||||
allowCreate: true,
|
||||
type: 'email',
|
||||
modelValue: ['jane@example.com'],
|
||||
});
|
||||
});
|
||||
|
||||
it('turns contacts with an email into menu items', () => {
|
||||
const wrapper = mountInput({ contacts });
|
||||
|
||||
expect(wrapper.findComponent(TagInput).props('menuItems')).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
label: 'jane@example.com',
|
||||
email: 'jane@example.com',
|
||||
thumbnail: { name: 'Jane', src: '' },
|
||||
value: 1,
|
||||
action: 'email',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'john@example.com',
|
||||
email: 'john@example.com',
|
||||
thumbnail: { name: 'John', src: '' },
|
||||
value: 3,
|
||||
action: 'email',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards model updates, search input and click outside', async () => {
|
||||
const wrapper = mountInput();
|
||||
const tagInput = wrapper.findComponent(TagInput);
|
||||
const event = { target: { value: 'ja' } };
|
||||
|
||||
await tagInput.vm.$emit('update:modelValue', ['jane@example.com']);
|
||||
await tagInput.vm.$emit('input', event);
|
||||
await tagInput.vm.$emit('onClickOutside');
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([
|
||||
[['jane@example.com']],
|
||||
]);
|
||||
expect(wrapper.emitted('input')).toEqual([[event]]);
|
||||
expect(wrapper.emitted('onClickOutside')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders trailing slot content next to the input', () => {
|
||||
const wrapper = mountInput(
|
||||
{},
|
||||
{ slots: { default: '<button class="bcc">Bcc</button>' } }
|
||||
);
|
||||
|
||||
expect(wrapper.find('button.bcc').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ import WhatsappFlowResponseBubble from './bubbles/WhatsappFlowResponse.vue';
|
||||
import WhatsappReferral from './bubbles/Text/WhatsappReferral.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ForwardEmailPanel from './forward/ForwardEmailPanel.vue';
|
||||
import ForwardedEmailBanner from './forward/ForwardedEmailBanner.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
@@ -145,6 +147,8 @@ const emit = defineEmits(['retry']);
|
||||
const contextMenuPosition = ref({});
|
||||
const showBackgroundHighlight = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const showForwardEmail = ref(false);
|
||||
const forwardEmailPosition = ref({});
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
@@ -371,6 +375,10 @@ const isMessageDeleted = computed(() => {
|
||||
return props.contentAttributes?.deleted;
|
||||
});
|
||||
|
||||
const isForwardedEmail = computed(
|
||||
() => !!props.contentAttributes?.forwardedMessageId && !isMessageDeleted.value
|
||||
);
|
||||
|
||||
const shouldShowWhatsappReferral = computed(
|
||||
() =>
|
||||
variant.value === MESSAGE_VARIANTS.USER &&
|
||||
@@ -408,6 +416,11 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
forwardEmail:
|
||||
props.isEmailInbox &&
|
||||
!props.private &&
|
||||
!isFailedOrProcessing &&
|
||||
!isMessageDeleted.value,
|
||||
report:
|
||||
isOnChatwootCloud.value &&
|
||||
isCaptainMessage.value &&
|
||||
@@ -463,6 +476,11 @@ function closeContextMenu() {
|
||||
contextMenuPosition.value = { x: null, y: null };
|
||||
}
|
||||
|
||||
function openForwardEmail() {
|
||||
forwardEmailPosition.value = { ...contextMenuPosition.value };
|
||||
showForwardEmail.value = true;
|
||||
}
|
||||
|
||||
function handleReplyTo() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
const { conversationId, id: replyTo } = props;
|
||||
@@ -588,7 +606,8 @@ provideMessageContext({
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'flex-col items-start gap-2': shouldShowWhatsappReferral,
|
||||
'flex-col items-start gap-2':
|
||||
shouldShowWhatsappReferral || isForwardedEmail,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
@@ -596,6 +615,7 @@ provideMessageContext({
|
||||
v-if="shouldShowWhatsappReferral"
|
||||
:referral="contentAttributes.referral"
|
||||
/>
|
||||
<ForwardedEmailBanner v-if="isForwardedEmail" />
|
||||
<Component :is="componentToRender" />
|
||||
</div>
|
||||
<MessageError
|
||||
@@ -617,6 +637,13 @@ provideMessageContext({
|
||||
@open="openContextMenu"
|
||||
@close="closeContextMenu"
|
||||
@reply-to="handleReplyTo"
|
||||
@forward-email="openForwardEmail"
|
||||
/>
|
||||
<ForwardEmailPanel
|
||||
v-if="showForwardEmail"
|
||||
:x="forwardEmailPosition.x"
|
||||
:y="forwardEmailPosition.y"
|
||||
@close="showForwardEmail = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,12 +81,18 @@ const fullHTML = computed(() => {
|
||||
return originalEmailHtml.value;
|
||||
});
|
||||
|
||||
const unquotedHTML = computed(() =>
|
||||
EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||
const isForwarded = computed(
|
||||
() => !!contentAttributes.value?.forwardedMessageId
|
||||
);
|
||||
|
||||
const hasQuotedMessage = computed(() =>
|
||||
EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||
const unquotedHTML = computed(() =>
|
||||
isForwarded.value
|
||||
? fullHTML.value
|
||||
: EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
const hasQuotedMessage = computed(
|
||||
() => !isForwarded.value && EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
// Ensure unique keys for <Letter> when toggling between original and translated views.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import EmailBubble from '../Index.vue';
|
||||
import { provideMessageContext } from '../../../provider.js';
|
||||
import { MESSAGE_TYPES } from '../../../constants';
|
||||
|
||||
vi.mock('dashboard/composables/useTranslations', async () => {
|
||||
const { ref: makeRef } = await import('vue');
|
||||
return {
|
||||
useTranslations: () => ({
|
||||
hasTranslations: makeRef(false),
|
||||
translationContent: makeRef(''),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const html = '<p>Latest reply</p><div class="gmail_quote">Older reply</div>';
|
||||
|
||||
const mountBubble = contentAttributes => {
|
||||
const Host = defineComponent({
|
||||
components: { EmailBubble },
|
||||
setup() {
|
||||
provideMessageContext({
|
||||
content: ref('Latest reply'),
|
||||
contentAttributes: ref({
|
||||
email: {
|
||||
htmlContent: { full: html },
|
||||
textContent: { full: 'Latest reply' },
|
||||
},
|
||||
...contentAttributes,
|
||||
}),
|
||||
attachments: ref([]),
|
||||
messageType: ref(MESSAGE_TYPES.OUTGOING),
|
||||
});
|
||||
},
|
||||
template: '<EmailBubble />',
|
||||
});
|
||||
|
||||
return mount(Host, {
|
||||
global: {
|
||||
mocks: { $store: { getters: { getSelectedChatAttachments: [] } } },
|
||||
stubs: {
|
||||
BaseBubble: { template: '<div><slot /></div>' },
|
||||
EmailMeta: true,
|
||||
AttachmentChips: true,
|
||||
TranslationToggle: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('Email bubble', () => {
|
||||
it('collapses quoted history behind the toggle for regular emails', () => {
|
||||
const wrapper = mountBubble({});
|
||||
const letter = wrapper.find('.letter-render');
|
||||
|
||||
expect(letter.text()).toContain('Latest reply');
|
||||
expect(letter.text()).not.toContain('Older reply');
|
||||
expect(wrapper.text()).toContain('CHAT_LIST.SHOW_QUOTED_TEXT');
|
||||
});
|
||||
|
||||
it('shows the full body for a forwarded email', () => {
|
||||
const wrapper = mountBubble({ forwardedMessageId: 501 });
|
||||
const letter = wrapper.find('.letter-render');
|
||||
|
||||
expect(letter.text()).toContain('Latest reply');
|
||||
expect(letter.text()).toContain('Older reply');
|
||||
expect(wrapper.text()).not.toContain('CHAT_LIST.SHOW_QUOTED_TEXT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { Letter } from 'vue-letter';
|
||||
import { allowedCssProperties } from 'lettersanitizer';
|
||||
|
||||
defineProps({
|
||||
html: { type: String, required: true },
|
||||
});
|
||||
|
||||
const letterRef = useTemplateRef('letterRef');
|
||||
|
||||
defineExpose({
|
||||
getContent: () => ({
|
||||
html: letterRef.value.$el.innerHTML,
|
||||
text: letterRef.value.$el.innerText.trim(),
|
||||
}),
|
||||
undo: () => {
|
||||
letterRef.value.$el.focus();
|
||||
document.execCommand('undo');
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Letter
|
||||
ref="letterRef"
|
||||
contenteditable="true"
|
||||
class-name="prose prose-bubble !max-w-none letter-render outline-none"
|
||||
:html="html"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
'transform-origin',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { parseISO, isValid } from 'date-fns';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { debounce, getFileInfo } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getReplyVariables,
|
||||
resolveVariablesInMessage,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { formatQuotedEmailDate } from 'dashboard/helper/quotedEmailHelper';
|
||||
import {
|
||||
buildForwardedEmailHtml,
|
||||
textToHtml,
|
||||
} from 'dashboard/helper/forwardEmailHelper';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import InboxSelector from 'dashboard/components-next/NewConversation/components/InboxSelector.vue';
|
||||
import RecipientsInput from 'dashboard/components-next/NewConversation/components/RecipientsInput.vue';
|
||||
import MessageEditor from 'dashboard/components-next/NewConversation/components/MessageEditor.vue';
|
||||
import AttachmentPreviews from 'dashboard/components-next/NewConversation/components/AttachmentPreviews.vue';
|
||||
import ActionButtons from 'dashboard/components-next/NewConversation/components/ActionButtons.vue';
|
||||
import EditableEmailBody from './EditableEmailBody.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES } from '../constants';
|
||||
|
||||
defineProps({
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
const {
|
||||
id: messageId,
|
||||
content,
|
||||
contentAttributes,
|
||||
attachments,
|
||||
messageType,
|
||||
sender,
|
||||
createdAt,
|
||||
conversationId,
|
||||
inboxId,
|
||||
} = useMessageContext();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const messageSignature = useMapGetter('getMessageSignature');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const inbox = computed(() => useCamelCase(inboxGetter.value(inboxId.value)));
|
||||
const contact = computed(() => currentChat.value?.meta?.sender ?? {});
|
||||
const variables = computed(() =>
|
||||
getReplyVariables({
|
||||
conversation: currentChat.value,
|
||||
contact: contact.value,
|
||||
inbox: inbox.value,
|
||||
user: currentUser.value,
|
||||
})
|
||||
);
|
||||
const sendWithSignature = computed(() =>
|
||||
fetchSignatureFlagFromUISettings(INBOX_TYPES.EMAIL)
|
||||
);
|
||||
|
||||
const attachmentName = ({ dataUrl }) => {
|
||||
const { base, type } = getFileInfo(dataUrl);
|
||||
return type ? `${base}.${type}` : base;
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
toEmails: [],
|
||||
ccEmails: [],
|
||||
bccEmails: [],
|
||||
message: '',
|
||||
attachedFiles: attachments.value.map(attachment => ({
|
||||
forwardedAttachmentId: attachment.id,
|
||||
thumb: attachment.thumbUrl,
|
||||
resource: {
|
||||
id: `forwarded-${attachment.id}`,
|
||||
name: attachmentName(attachment),
|
||||
type: attachment.fileType,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const emailMeta = contentAttributes.value?.email ?? {};
|
||||
const emailDate = parseISO(emailMeta.date ?? '');
|
||||
const recipients = emailMeta.to ?? contentAttributes.value?.toEmails ?? [];
|
||||
const forwardedHtml = buildForwardedEmailHtml({
|
||||
labels: {
|
||||
title: t('FORWARD_EMAIL.HEADER_TITLE'),
|
||||
from: t('EMAIL_HEADER.FROM'),
|
||||
date: t('EMAIL_HEADER.DATE'),
|
||||
subject: t('EMAIL_HEADER.SUBJECT'),
|
||||
to: t('EMAIL_HEADER.TO'),
|
||||
},
|
||||
sender:
|
||||
messageType.value === MESSAGE_TYPES.INCOMING
|
||||
? {
|
||||
name: sender.value?.name,
|
||||
email: emailMeta.from?.[0] ?? sender.value?.email,
|
||||
}
|
||||
: { name: inbox.value.name, email: inbox.value.email },
|
||||
date: formatQuotedEmailDate(
|
||||
isValid(emailDate) ? emailDate : new Date(createdAt.value * 1000)
|
||||
),
|
||||
subject:
|
||||
emailMeta.subject ||
|
||||
currentChat.value?.additional_attributes?.mail_subject ||
|
||||
'',
|
||||
recipients: recipients.length
|
||||
? recipients
|
||||
: [contact.value.email].filter(Boolean),
|
||||
bodyHtml:
|
||||
emailMeta.htmlContent?.full ||
|
||||
textToHtml(emailMeta.textContent?.full || content.value || ''),
|
||||
});
|
||||
|
||||
const bodyRef = useTemplateRef('bodyRef');
|
||||
|
||||
useKeyboardEvents({ '$mod+z': () => bodyRef.value.undo() });
|
||||
|
||||
// Keeps pasted and dropped files out of the reply box while this composer is open
|
||||
onMounted(() => emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true));
|
||||
onBeforeUnmount(() => emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false));
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
const contacts = ref([]);
|
||||
const isSearching = ref(false);
|
||||
const showBcc = ref(false);
|
||||
const activeRecipientField = ref(null);
|
||||
|
||||
const closeRecipientDropdown = field => {
|
||||
if (activeRecipientField.value === field) activeRecipientField.value = null;
|
||||
};
|
||||
|
||||
const onSearchContacts = debounce(
|
||||
async (field, query) => {
|
||||
activeRecipientField.value = query.trim().length >= 2 ? field : null;
|
||||
if (!activeRecipientField.value) return;
|
||||
|
||||
isSearching.value = true;
|
||||
try {
|
||||
const results = await searchContacts(query);
|
||||
if (results === null) return;
|
||||
contacts.value = results;
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
isSearching.value = false;
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
const onAddSignature = signature => {
|
||||
state.message = appendSignature(state.message, signature, INBOX_TYPES.EMAIL);
|
||||
};
|
||||
|
||||
const onRemoveSignature = signature => {
|
||||
state.message = removeSignature(state.message, signature, INBOX_TYPES.EMAIL);
|
||||
};
|
||||
|
||||
const canSend = computed(() => state.toEmails.length > 0);
|
||||
|
||||
const forwardEmail = () => {
|
||||
if (!canSend.value) return;
|
||||
|
||||
const body = bodyRef.value.getContent();
|
||||
// Pasted variables skip the editor's input rules; the note is the only part rendered as HTML
|
||||
const note = resolveVariablesInMessage(state.message, variables.value);
|
||||
const noteHtml = new MessageFormatter(note).formattedMessage;
|
||||
const forwardedFiles = state.attachedFiles.filter(
|
||||
file => file.forwardedAttachmentId
|
||||
);
|
||||
const uploadedFiles = state.attachedFiles.filter(
|
||||
file => !file.forwardedAttachmentId
|
||||
);
|
||||
|
||||
store.dispatch('createPendingMessageAndSend', {
|
||||
conversationId: conversationId.value,
|
||||
message: [note, body.text].filter(Boolean).join('\n\n'),
|
||||
emailHtmlContent: `${noteHtml}${body.html}`,
|
||||
toEmails: state.toEmails.join(','),
|
||||
ccEmails: state.ccEmails.join(','),
|
||||
bccEmails: state.bccEmails.join(','),
|
||||
private: false,
|
||||
contentAttributes: { forwarded_message_id: messageId.value },
|
||||
forwardedAttachmentIds: forwardedFiles.map(
|
||||
file => file.forwardedAttachmentId
|
||||
),
|
||||
files: uploadedFiles.map(file =>
|
||||
globalConfig.value.directUploadsEnabled
|
||||
? file.blobSignedId
|
||||
: file.resource.file
|
||||
),
|
||||
sender: {
|
||||
name: currentUser.value.name,
|
||||
thumbnail: currentUser.value.avatar_url,
|
||||
},
|
||||
});
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu :x="x" :y="y" :close-on-focus-out="false" @close="emit('close')">
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
() => emit('close'),
|
||||
{
|
||||
ignore: ['.ProseMirror-prompt', 'dialog.ProseMirror-prompt-backdrop'],
|
||||
},
|
||||
]"
|
||||
class="w-full md:w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)] cursor-default"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||
<InboxSelector
|
||||
:target-inbox="inbox"
|
||||
:show-inboxes-dropdown="false"
|
||||
:removable="false"
|
||||
/>
|
||||
<RecipientsInput
|
||||
v-model="state.toEmails"
|
||||
:label="t('FORWARD_EMAIL.TO_LABEL')"
|
||||
:placeholder="t('FORWARD_EMAIL.TO_PLACEHOLDER')"
|
||||
:contacts="contacts"
|
||||
:show-dropdown="activeRecipientField === 'to'"
|
||||
:is-loading="isSearching"
|
||||
@input="onSearchContacts('to', $event)"
|
||||
@on-click-outside="closeRecipientDropdown('to')"
|
||||
/>
|
||||
<RecipientsInput
|
||||
v-model="state.ccEmails"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_LABEL')"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.CC_PLACEHOLDER')
|
||||
"
|
||||
:contacts="contacts"
|
||||
:show-dropdown="activeRecipientField === 'cc'"
|
||||
:is-loading="isSearching"
|
||||
@input="onSearchContacts('cc', $event)"
|
||||
@on-click-outside="closeRecipientDropdown('cc')"
|
||||
>
|
||||
<Button
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_BUTTON')"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="flex-shrink-0"
|
||||
@click="showBcc = !showBcc"
|
||||
/>
|
||||
</RecipientsInput>
|
||||
<RecipientsInput
|
||||
v-if="showBcc"
|
||||
v-model="state.bccEmails"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_LABEL')"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS.BCC_PLACEHOLDER')
|
||||
"
|
||||
:contacts="contacts"
|
||||
:show-dropdown="activeRecipientField === 'bcc'"
|
||||
:is-loading="isSearching"
|
||||
focus-on-mount
|
||||
@input="onSearchContacts('bcc', $event)"
|
||||
@on-click-outside="closeRecipientDropdown('bcc')"
|
||||
/>
|
||||
<div>
|
||||
<MessageEditor
|
||||
v-model="state.message"
|
||||
:placeholder="t('FORWARD_EMAIL.MESSAGE_PLACEHOLDER')"
|
||||
compact
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="INBOX_TYPES.EMAIL"
|
||||
:variables="variables"
|
||||
/>
|
||||
<EditableEmailBody
|
||||
ref="bodyRef"
|
||||
:html="forwardedHtml"
|
||||
class="px-4 pb-4"
|
||||
/>
|
||||
</div>
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
</div>
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
is-email-or-web-widget-inbox
|
||||
has-selected-inbox
|
||||
:channel-type="INBOX_TYPES.EMAIL"
|
||||
:inbox-id="inboxId"
|
||||
:disable-send-button="!canSend"
|
||||
:is-dropdown-active="activeRecipientField !== null"
|
||||
:message-signature="messageSignature"
|
||||
@insert-emoji="state.message += $event"
|
||||
@add-signature="onAddSignature"
|
||||
@remove-signature="onRemoveSignature"
|
||||
@attach-file="state.attachedFiles = $event"
|
||||
@discard="emit('close')"
|
||||
@send-message="forwardEmail"
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
const recipients = computed(() => [
|
||||
...(contentAttributes.value.toEmails ?? []),
|
||||
...(contentAttributes.value.ccEmails ?? []),
|
||||
]);
|
||||
const contact = computed(() => currentChat.value?.meta?.sender ?? {});
|
||||
const hiddenFromContact = computed(
|
||||
() =>
|
||||
![...recipients.value, ...(contentAttributes.value.bccEmails ?? [])]
|
||||
.map(email => email.toLowerCase())
|
||||
.includes(contact.value.email?.toLowerCase())
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium">
|
||||
<Icon icon="i-lucide-forward" class="size-4 text-n-amber-10" />
|
||||
<span class="text-n-amber-10">
|
||||
{{ t('FORWARD_EMAIL.FORWARDED_TO', { emails: recipients.join(', ') }) }}
|
||||
</span>
|
||||
<template v-if="hiddenFromContact">
|
||||
<span class="w-px h-3 bg-n-slate-7" />
|
||||
<span class="text-n-slate-11">
|
||||
{{ t('FORWARD_EMAIL.CONTACT_CANNOT_SEE', { name: contact.name }) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import EditableEmailBody from '../EditableEmailBody.vue';
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'innerText', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.textContent;
|
||||
},
|
||||
});
|
||||
|
||||
describe('EditableEmailBody', () => {
|
||||
beforeEach(() => {
|
||||
document.execCommand = vi.fn();
|
||||
});
|
||||
|
||||
it('renders the html as an editable document', () => {
|
||||
const wrapper = mount(EditableEmailBody, {
|
||||
props: { html: '<p>Hello <b>there</b></p>' },
|
||||
});
|
||||
const editable = wrapper.find('[contenteditable="true"]');
|
||||
|
||||
expect(editable.exists()).toBe(true);
|
||||
expect(editable.html()).toContain('<p>Hello <b>there</b></p>');
|
||||
});
|
||||
|
||||
it('returns the current html and text after edits', () => {
|
||||
const wrapper = mount(EditableEmailBody, {
|
||||
props: { html: '<p>Hello</p>' },
|
||||
});
|
||||
|
||||
wrapper.find('[contenteditable="true"]').element.innerHTML =
|
||||
'<p>Edited</p> ';
|
||||
|
||||
expect(wrapper.vm.getContent()).toEqual({
|
||||
html: '<p>Edited</p> ',
|
||||
text: 'Edited',
|
||||
});
|
||||
});
|
||||
|
||||
it('focuses the document before running the browser undo', () => {
|
||||
const wrapper = mount(EditableEmailBody, {
|
||||
props: { html: '<p>Hello</p>' },
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
wrapper.vm.undo();
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
wrapper.find('[contenteditable="true"]').element
|
||||
);
|
||||
expect(document.execCommand).toHaveBeenCalledWith('undo');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { withFullI18n } from 'test-i18n';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import { formatQuotedEmailDate } from 'dashboard/helper/quotedEmailHelper';
|
||||
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ForwardEmailPanel from '../ForwardEmailPanel.vue';
|
||||
import { provideMessageContext } from '../../provider.js';
|
||||
import { MESSAGE_TYPES } from '../../constants';
|
||||
|
||||
vi.mock('dashboard/composables/useUISettings', () => ({
|
||||
useUISettings: () => ({ fetchSignatureFlagFromUISettings: () => false }),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
'dashboard/components-next/NewConversation/helpers/composeConversationHelper',
|
||||
async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
createContactSearcher: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
withFullI18n();
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'innerText', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.textContent;
|
||||
},
|
||||
});
|
||||
|
||||
const stub = (name, props, emits = []) => ({
|
||||
name,
|
||||
props,
|
||||
emits,
|
||||
template: '<div><slot /></div>',
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['x', 'y', 'closeOnFocusOut'],
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
InboxSelector: stub('InboxSelector', ['targetInbox', 'removable']),
|
||||
RecipientsInput: stub(
|
||||
'RecipientsInput',
|
||||
['modelValue', 'contacts', 'showDropdown', 'isLoading'],
|
||||
['update:modelValue', 'input', 'onClickOutside']
|
||||
),
|
||||
MessageEditor: stub(
|
||||
'MessageEditor',
|
||||
{ modelValue: String, placeholder: String, compact: Boolean },
|
||||
['update:modelValue']
|
||||
),
|
||||
AttachmentPreviews: stub(
|
||||
'AttachmentPreviews',
|
||||
['attachments'],
|
||||
['update:attachments']
|
||||
),
|
||||
ActionButtons: stub(
|
||||
'ActionButtons',
|
||||
['disableSendButton', 'attachedFiles', 'isDropdownActive'],
|
||||
['sendMessage', 'discard', 'attachFile']
|
||||
),
|
||||
};
|
||||
|
||||
const contact = { name: 'Jane Doe', email: 'jane@example.com' };
|
||||
const inbox = {
|
||||
id: 9,
|
||||
name: 'Support',
|
||||
email: 'support@example.com',
|
||||
channel_type: 'Channel::Email',
|
||||
};
|
||||
const email = {
|
||||
from: ['jane@example.com'],
|
||||
to: ['support@example.com'],
|
||||
date: '2026-09-17T09:39:00Z',
|
||||
subject: 'Order #42',
|
||||
htmlContent: { full: '<p>Original body</p>' },
|
||||
textContent: { full: 'Original body' },
|
||||
};
|
||||
const attachment = {
|
||||
id: 7,
|
||||
thumbUrl: 'https://cdn.example.com/thumb.png',
|
||||
dataUrl: 'https://cdn.example.com/files/invoice.pdf',
|
||||
fileType: 'file',
|
||||
};
|
||||
|
||||
const mountPanel = ({ message = {}, directUploadsEnabled = false } = {}) => {
|
||||
const sendMessage = vi.fn();
|
||||
const store = createStore({
|
||||
getters: {
|
||||
getSelectedChat: () => ({
|
||||
meta: { sender: contact },
|
||||
additional_attributes: { mail_subject: 'Conversation subject' },
|
||||
}),
|
||||
getCurrentUser: () => ({
|
||||
name: 'Agent Smith',
|
||||
avatar_url: 'https://cdn.example.com/agent.png',
|
||||
}),
|
||||
'globalConfig/get': () => ({ directUploadsEnabled }),
|
||||
getMessageSignature: () => '',
|
||||
'inboxes/getInbox': () => () => inbox,
|
||||
getSelectedChatAttachments: () => [],
|
||||
},
|
||||
actions: { createPendingMessageAndSend: sendMessage },
|
||||
});
|
||||
const context = {
|
||||
id: 501,
|
||||
content: 'Plain fallback',
|
||||
contentAttributes: { email },
|
||||
attachments: [attachment],
|
||||
messageType: MESSAGE_TYPES.INCOMING,
|
||||
sender: contact,
|
||||
createdAt: 1789000000,
|
||||
conversationId: 77,
|
||||
inboxId: 9,
|
||||
...message,
|
||||
};
|
||||
const Host = defineComponent({
|
||||
components: { ForwardEmailPanel },
|
||||
setup() {
|
||||
provideMessageContext(
|
||||
Object.fromEntries(
|
||||
Object.entries(context).map(([key, value]) => [key, ref(value)])
|
||||
)
|
||||
);
|
||||
},
|
||||
template: '<ForwardEmailPanel :x="10" :y="20" @close="$emit(\'close\')" />',
|
||||
});
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [store], stubs },
|
||||
});
|
||||
|
||||
return { wrapper, sendMessage };
|
||||
};
|
||||
|
||||
const sentPayload = sendMessage => sendMessage.mock.calls[0][1];
|
||||
|
||||
const find = (wrapper, name) => wrapper.findComponent({ name });
|
||||
const bodyElement = wrapper => wrapper.find('[contenteditable="true"]').element;
|
||||
|
||||
const addRecipient = wrapper =>
|
||||
find(wrapper, 'RecipientsInput').vm.$emit('update:modelValue', [
|
||||
'vendor@example.com',
|
||||
]);
|
||||
|
||||
describe('ForwardEmailPanel', () => {
|
||||
let searchContacts;
|
||||
|
||||
beforeEach(() => {
|
||||
document.execCommand = vi.fn();
|
||||
searchContacts = vi.fn().mockResolvedValue([]);
|
||||
createContactSearcher.mockReturnValue(searchContacts);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('prefills the gmail style header, original body and attachments', () => {
|
||||
const { wrapper } = mountPanel();
|
||||
const text = wrapper.text();
|
||||
|
||||
expect(text).toContain('---------- Forwarded message ---------');
|
||||
expect(text).toContain('From: Jane Doe <jane@example.com>');
|
||||
expect(text).toContain(
|
||||
`Date: ${formatQuotedEmailDate(parseISO(email.date))}`
|
||||
);
|
||||
expect(text).toContain('Subject: Order #42');
|
||||
expect(text).toContain('To: <support@example.com>');
|
||||
expect(text).toContain('Original body');
|
||||
|
||||
expect(find(wrapper, 'InboxSelector').props()).toMatchObject({
|
||||
targetInbox: { name: 'Support', email: 'support@example.com' },
|
||||
removable: false,
|
||||
});
|
||||
expect(find(wrapper, 'MessageEditor').props('compact')).toBe(true);
|
||||
expect(find(wrapper, 'AttachmentPreviews').props('attachments')).toEqual([
|
||||
{
|
||||
forwardedAttachmentId: 7,
|
||||
thumb: attachment.thumbUrl,
|
||||
resource: { id: 'forwarded-7', name: 'invoice.pdf', type: 'file' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the inbox as sender when forwarding an outgoing email', () => {
|
||||
const { wrapper } = mountPanel({
|
||||
message: { messageType: MESSAGE_TYPES.OUTGOING },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('From: Support <support@example.com>');
|
||||
});
|
||||
|
||||
it('uses the inbox as sender when forwarding a template message', () => {
|
||||
const { wrapper } = mountPanel({
|
||||
message: { messageType: MESSAGE_TYPES.TEMPLATE, sender: null },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('From: Support <support@example.com>');
|
||||
});
|
||||
|
||||
it('falls back to plain text, the conversation subject and the contact', () => {
|
||||
const { wrapper } = mountPanel({
|
||||
message: {
|
||||
content: 'Line one\nLine two',
|
||||
contentAttributes: {},
|
||||
attachments: [],
|
||||
},
|
||||
});
|
||||
const text = wrapper.text();
|
||||
|
||||
expect(text).toContain('Line one');
|
||||
expect(text).toContain('Line two');
|
||||
expect(text).toContain('Subject: Conversation subject');
|
||||
expect(text).toContain('To: <jane@example.com>');
|
||||
expect(text).toContain(
|
||||
`Date: ${formatQuotedEmailDate(new Date(1789000000 * 1000))}`
|
||||
);
|
||||
expect(find(wrapper, 'AttachmentPreviews').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('enables send only once a recipient is added', async () => {
|
||||
const { wrapper, sendMessage } = mountPanel();
|
||||
const actions = find(wrapper, 'ActionButtons');
|
||||
|
||||
expect(actions.props('disableSendButton')).toBe(true);
|
||||
await actions.vm.$emit('sendMessage');
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
await addRecipient(wrapper);
|
||||
expect(actions.props('disableSendButton')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends the note, the edited body and the kept attachments', async () => {
|
||||
const { wrapper, sendMessage } = mountPanel();
|
||||
|
||||
await addRecipient(wrapper);
|
||||
await find(wrapper, 'MessageEditor').vm.$emit(
|
||||
'update:modelValue',
|
||||
'Please handle this'
|
||||
);
|
||||
bodyElement(wrapper).innerHTML = '<p>Edited body</p>';
|
||||
await find(wrapper, 'ActionButtons').vm.$emit('sendMessage');
|
||||
|
||||
expect(sentPayload(sendMessage)).toEqual({
|
||||
conversationId: 77,
|
||||
message: 'Please handle this\n\nEdited body',
|
||||
emailHtmlContent: `${new MessageFormatter('Please handle this').formattedMessage}<p>Edited body</p>`,
|
||||
toEmails: 'vendor@example.com',
|
||||
ccEmails: '',
|
||||
bccEmails: '',
|
||||
private: false,
|
||||
contentAttributes: { forwarded_message_id: 501 },
|
||||
forwardedAttachmentIds: [7],
|
||||
files: [],
|
||||
sender: {
|
||||
name: 'Agent Smith',
|
||||
thumbnail: 'https://cdn.example.com/agent.png',
|
||||
},
|
||||
});
|
||||
expect(wrapper.emitted('close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resolves variables in the note before building the email', async () => {
|
||||
const { wrapper, sendMessage } = mountPanel();
|
||||
|
||||
await addRecipient(wrapper);
|
||||
await find(wrapper, 'MessageEditor').vm.$emit(
|
||||
'update:modelValue',
|
||||
'Hello {{contact.name}}, please help.'
|
||||
);
|
||||
bodyElement(wrapper).innerHTML = '<p>Edited body</p>';
|
||||
await find(wrapper, 'ActionButtons').vm.$emit('sendMessage');
|
||||
|
||||
const payload = sentPayload(sendMessage);
|
||||
expect(payload.message).toBe('Hello Jane Doe, please help.\n\nEdited body');
|
||||
expect(payload.emailHtmlContent).toContain('Hello Jane Doe, please help.');
|
||||
});
|
||||
|
||||
it('drops removed original attachments and sends newly attached files', async () => {
|
||||
const { wrapper, sendMessage } = mountPanel();
|
||||
const file = new File(['pdf'], 'new.pdf', { type: 'application/pdf' });
|
||||
|
||||
await addRecipient(wrapper);
|
||||
await find(wrapper, 'AttachmentPreviews').vm.$emit(
|
||||
'update:attachments',
|
||||
[]
|
||||
);
|
||||
await find(wrapper, 'ActionButtons').vm.$emit('attachFile', [
|
||||
{ resource: { file, name: 'new.pdf', type: 'application/pdf' } },
|
||||
]);
|
||||
await find(wrapper, 'ActionButtons').vm.$emit('sendMessage');
|
||||
|
||||
expect(sentPayload(sendMessage)).toMatchObject({
|
||||
forwardedAttachmentIds: [],
|
||||
files: [file],
|
||||
});
|
||||
});
|
||||
|
||||
it('sends signed blob ids when direct uploads are enabled', async () => {
|
||||
const { wrapper, sendMessage } = mountPanel({ directUploadsEnabled: true });
|
||||
const actions = find(wrapper, 'ActionButtons');
|
||||
|
||||
await addRecipient(wrapper);
|
||||
await actions.vm.$emit('attachFile', [
|
||||
...actions.props('attachedFiles'),
|
||||
{ blobSignedId: 'signed-id', resource: { name: 'new.pdf' } },
|
||||
]);
|
||||
await find(wrapper, 'ActionButtons').vm.$emit('sendMessage');
|
||||
|
||||
expect(sentPayload(sendMessage)).toMatchObject({
|
||||
forwardedAttachmentIds: [7],
|
||||
files: ['signed-id'],
|
||||
});
|
||||
});
|
||||
|
||||
it('searches contacts once two characters are typed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const contacts = [{ id: 1, name: 'Vendor', email: 'vendor@example.com' }];
|
||||
searchContacts.mockResolvedValue(contacts);
|
||||
const { wrapper } = mountPanel();
|
||||
const recipients = find(wrapper, 'RecipientsInput');
|
||||
|
||||
await recipients.vm.$emit('input', 'v');
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(searchContacts).not.toHaveBeenCalled();
|
||||
expect(recipients.props('showDropdown')).toBe(false);
|
||||
|
||||
await recipients.vm.$emit('input', 've');
|
||||
vi.advanceTimersByTime(400);
|
||||
await flushPromises();
|
||||
|
||||
expect(searchContacts).toHaveBeenCalledWith('ve');
|
||||
expect(recipients.props()).toMatchObject({
|
||||
showDropdown: true,
|
||||
isLoading: false,
|
||||
contacts,
|
||||
});
|
||||
|
||||
await recipients.vm.$emit('onClickOutside');
|
||||
expect(recipients.props('showDropdown')).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('opens the contact dropdown only on the row being typed in', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { wrapper } = mountPanel();
|
||||
const [to, cc] = wrapper.findAllComponents({ name: 'RecipientsInput' });
|
||||
|
||||
await cc.vm.$emit('input', 'ab');
|
||||
vi.advanceTimersByTime(400);
|
||||
await flushPromises();
|
||||
|
||||
expect(cc.props('showDropdown')).toBe(true);
|
||||
expect(to.props('showDropdown')).toBe(false);
|
||||
expect(find(wrapper, 'ActionButtons').props('isDropdownActive')).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps a row dropdown open when a sibling row reports a click outside', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { wrapper } = mountPanel();
|
||||
const [to, cc] = wrapper.findAllComponents({ name: 'RecipientsInput' });
|
||||
|
||||
await to.vm.$emit('input', 'ab');
|
||||
vi.advanceTimersByTime(400);
|
||||
await flushPromises();
|
||||
|
||||
await cc.vm.$emit('onClickOutside');
|
||||
expect(to.props('showDropdown')).toBe(true);
|
||||
|
||||
await to.vm.$emit('onClickOutside');
|
||||
expect(to.props('showDropdown')).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sends cc and bcc recipients', async () => {
|
||||
const { wrapper, sendMessage } = mountPanel();
|
||||
|
||||
expect(wrapper.findAllComponents({ name: 'RecipientsInput' })).toHaveLength(
|
||||
2
|
||||
);
|
||||
await wrapper.findComponent(Button).trigger('click');
|
||||
const [, cc, bcc] = wrapper.findAllComponents({ name: 'RecipientsInput' });
|
||||
|
||||
await addRecipient(wrapper);
|
||||
await cc.vm.$emit('update:modelValue', ['ops@example.com']);
|
||||
await bcc.vm.$emit('update:modelValue', ['audit@example.com']);
|
||||
await find(wrapper, 'ActionButtons').vm.$emit('sendMessage');
|
||||
|
||||
expect(sentPayload(sendMessage)).toMatchObject({
|
||||
toEmails: 'vendor@example.com',
|
||||
ccEmails: 'ops@example.com',
|
||||
bccEmails: 'audit@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on discard without sending', async () => {
|
||||
const { wrapper, sendMessage } = mountPanel();
|
||||
|
||||
await find(wrapper, 'ActionButtons').vm.$emit('discard');
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('undoes body edits from the keyboard shortcut when focus is elsewhere', async () => {
|
||||
const { wrapper } = mountPanel();
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'z',
|
||||
code: 'KeyZ',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(document.activeElement).toBe(bodyElement(wrapper));
|
||||
expect(document.execCommand).toHaveBeenCalledWith('undo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import { withFullI18n } from 'test-i18n';
|
||||
import ForwardedEmailBanner from '../ForwardedEmailBanner.vue';
|
||||
import { provideMessageContext } from '../../provider.js';
|
||||
|
||||
withFullI18n();
|
||||
|
||||
const mountBanner = ({ toEmails, ccEmails, bccEmails, contact }) => {
|
||||
const Host = defineComponent({
|
||||
components: { ForwardedEmailBanner },
|
||||
setup() {
|
||||
provideMessageContext({
|
||||
contentAttributes: ref({ toEmails, ccEmails, bccEmails }),
|
||||
});
|
||||
},
|
||||
template: '<ForwardedEmailBanner />',
|
||||
});
|
||||
|
||||
const store = createStore({
|
||||
getters: {
|
||||
getSelectedChat: () => ({ meta: { sender: contact } }),
|
||||
getSelectedChatAttachments: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
return mount(Host, {
|
||||
global: { plugins: [store], stubs: { Icon: true } },
|
||||
});
|
||||
};
|
||||
|
||||
describe('ForwardedEmailBanner', () => {
|
||||
it('tells the agent who received the forward and that the contact cannot see it', () => {
|
||||
const wrapper = mountBanner({
|
||||
toEmails: ['vendor@example.com', 'ops@example.com'],
|
||||
contact: { name: 'Jane', email: 'jane@example.com' },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Forwarded to vendor@example.com, ops@example.com'
|
||||
);
|
||||
expect(wrapper.text()).toContain('Jane cannot see this');
|
||||
});
|
||||
|
||||
it('drops the visibility note when the contact is a recipient', () => {
|
||||
const wrapper = mountBanner({
|
||||
toEmails: ['jane@example.com'],
|
||||
contact: { name: 'Jane', email: 'jane@example.com' },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Forwarded to jane@example.com');
|
||||
expect(wrapper.text()).not.toContain('cannot see this');
|
||||
});
|
||||
|
||||
it('matches the contact email regardless of case', () => {
|
||||
const wrapper = mountBanner({
|
||||
toEmails: ['Jane@Example.com'],
|
||||
contact: { name: 'Jane', email: 'jane@example.com' },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).not.toContain('cannot see this');
|
||||
});
|
||||
|
||||
it('lists cc recipients and treats a bcc contact as a recipient', () => {
|
||||
const wrapper = mountBanner({
|
||||
toEmails: ['vendor@example.com'],
|
||||
ccEmails: ['ops@example.com'],
|
||||
bccEmails: ['jane@example.com'],
|
||||
contact: { name: 'Jane', email: 'jane@example.com' },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Forwarded to vendor@example.com, ops@example.com'
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('jane@example.com');
|
||||
expect(wrapper.text()).not.toContain('cannot see this');
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirecti
|
||||
const props = defineProps({
|
||||
x: { type: Number, default: 0 },
|
||||
y: { type: Number, default: 0 },
|
||||
closeOnFocusOut: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
@@ -80,7 +81,7 @@ const handleClose = () => {
|
||||
const handleFocusOut = event => {
|
||||
// Keep the menu open while focus stays inside it (e.g. the label search
|
||||
// input); close it once focus leaves the menu entirely.
|
||||
if (menuRef.value?.contains(event.relatedTarget)) {
|
||||
if (!props.closeOnFocusOut || menuRef.value?.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ContextMenu from '../ContextMenu.vue';
|
||||
|
||||
const mountMenu = (props = {}) =>
|
||||
mount(ContextMenu, {
|
||||
props: { x: 10, y: 20, ...props },
|
||||
slots: { default: '<button class="inside">Inside</button>' },
|
||||
global: {
|
||||
stubs: { TeleportWithDirection: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('ContextMenu', () => {
|
||||
it('closes when focus leaves the menu', async () => {
|
||||
const wrapper = mountMenu();
|
||||
|
||||
await wrapper
|
||||
.find('[tabindex="0"]')
|
||||
.trigger('focusout', { relatedTarget: document.body });
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stays open while focus moves within the menu', async () => {
|
||||
const wrapper = mountMenu();
|
||||
|
||||
await wrapper
|
||||
.find('[tabindex="0"]')
|
||||
.trigger('focusout', { relatedTarget: wrapper.find('.inside').element });
|
||||
|
||||
expect(wrapper.emitted('close')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores focus loss when closeOnFocusOut is disabled', async () => {
|
||||
const wrapper = mountMenu({ closeOnFocusOut: false });
|
||||
|
||||
await wrapper
|
||||
.find('[tabindex="0"]')
|
||||
.trigger('focusout', { relatedTarget: document.body });
|
||||
|
||||
expect(wrapper.emitted('close')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -23,10 +23,7 @@ import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder
|
||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import {
|
||||
getMessageVariables,
|
||||
getUndefinedVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import { getUndefinedVariablesInMessage } from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
@@ -47,8 +44,7 @@ import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
getAgentVariables,
|
||||
getContactVariables,
|
||||
getReplyVariables,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useMacroExecution } from 'dashboard/composables/useMacroExecution';
|
||||
@@ -478,18 +474,12 @@ export default {
|
||||
return AUDIO_FORMATS.WAV;
|
||||
},
|
||||
messageVariables() {
|
||||
const variables = getMessageVariables({
|
||||
return getReplyVariables({
|
||||
conversation: this.currentChat,
|
||||
contact: this.currentContact,
|
||||
inbox: this.inbox,
|
||||
user: this.currentUser,
|
||||
});
|
||||
// Match the backend drops: names are Ruby-capitalized and
|
||||
// {{agent.*}} is the message sender, not the assignee.
|
||||
return {
|
||||
...variables,
|
||||
...getContactVariables(this.currentContact),
|
||||
...getAgentVariables(this.currentUser),
|
||||
};
|
||||
},
|
||||
connectedPortalSlug() {
|
||||
const { help_center: portal = {} } = this.inbox;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { getMessageVariables } from '@chatwoot/utils';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -491,6 +492,14 @@ export const getAgentVariables = user => ({
|
||||
export const getContactVariables = contact =>
|
||||
getNameVariables('contact', contact?.name);
|
||||
|
||||
// Variables the editor resolves while composing a reply. Matches the backend
|
||||
// drops: names are Ruby-capitalized and {{agent.*}} is the sender, not the assignee.
|
||||
export const getReplyVariables = ({ conversation, contact, inbox, user }) => ({
|
||||
...getMessageVariables({ conversation, contact, inbox }),
|
||||
...getContactVariables(contact),
|
||||
...getAgentVariables(user),
|
||||
});
|
||||
|
||||
// Resolves a manually typed {{variable}} to its value on the closing braces.
|
||||
// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note.
|
||||
export const createVariableInputRule = ({ isPrivate, getVariables }) => {
|
||||
|
||||
28
app/javascript/dashboard/helper/forwardEmailHelper.js
Normal file
28
app/javascript/dashboard/helper/forwardEmailHelper.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { escapeHtml } from 'shared/helpers/HTMLSanitizer';
|
||||
|
||||
const mailtoLink = email =>
|
||||
`<<a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a>>`;
|
||||
|
||||
const addressHtml = ({ name, email }) =>
|
||||
name ? `<b>${escapeHtml(name)}</b> ${mailtoLink(email)}` : mailtoLink(email);
|
||||
|
||||
export const textToHtml = text => escapeHtml(text).replace(/\n/g, '<br>');
|
||||
|
||||
export const buildForwardedEmailHtml = ({
|
||||
labels,
|
||||
sender,
|
||||
date,
|
||||
subject,
|
||||
recipients,
|
||||
bodyHtml,
|
||||
}) => {
|
||||
const header = [
|
||||
labels.title,
|
||||
`${labels.from}: ${addressHtml(sender)}`,
|
||||
`${labels.date}: ${escapeHtml(date)}`,
|
||||
`${labels.subject}: ${escapeHtml(subject)}`,
|
||||
`${labels.to}: ${recipients.map(mailtoLink).join(', ')}`,
|
||||
].join('<br>');
|
||||
|
||||
return `<div class="chatwoot_forward_header">${header}</div><br>${bodyHtml}`;
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findNodeToInsertImage,
|
||||
findSignatureInBody,
|
||||
getAgentVariables,
|
||||
getReplyVariables,
|
||||
getContactVariables,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
@@ -1313,6 +1314,26 @@ describe('Menu positioning helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReplyVariables', () => {
|
||||
it('resolves contact, conversation and sender variables', () => {
|
||||
const variables = getReplyVariables({
|
||||
conversation: {
|
||||
id: 7,
|
||||
meta: { sender: { name: 'jane doe' }, assignee: { name: 'Bot' } },
|
||||
},
|
||||
contact: { name: 'jane doe' },
|
||||
inbox: { id: 3, name: 'Support' },
|
||||
user: { name: 'john doe', email: 'john@example.com' },
|
||||
});
|
||||
|
||||
expect(variables['contact.name']).toBe('Jane Doe');
|
||||
expect(variables['conversation.id']).toBe(7);
|
||||
expect(variables['inbox.name']).toBe('Support');
|
||||
expect(variables['agent.name']).toBe('John Doe');
|
||||
expect(variables['agent.email']).toBe('john@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentVariables', () => {
|
||||
it('builds agent variables from the user', () => {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
buildForwardedEmailHtml,
|
||||
textToHtml,
|
||||
} from 'dashboard/helper/forwardEmailHelper';
|
||||
|
||||
const labels = {
|
||||
title: '---------- Forwarded message ---------',
|
||||
from: 'From',
|
||||
date: 'Date',
|
||||
subject: 'Subject',
|
||||
to: 'To',
|
||||
};
|
||||
|
||||
describe('forwardEmailHelper', () => {
|
||||
describe('textToHtml', () => {
|
||||
it('escapes html and converts line breaks', () => {
|
||||
expect(textToHtml('Hi <b>there</b>\nBye & thanks')).toBe(
|
||||
'Hi <b>there</b><br>Bye & thanks'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildForwardedEmailHtml', () => {
|
||||
const build = overrides =>
|
||||
buildForwardedEmailHtml({
|
||||
labels,
|
||||
sender: { name: 'Jane Doe', email: 'jane@example.com' },
|
||||
date: 'Thu, Sep 17, 2026 at 9:39 AM',
|
||||
subject: 'Order #42',
|
||||
recipients: ['support@example.com'],
|
||||
bodyHtml: '<p>Original body</p>',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('renders the gmail style header followed by the body', () => {
|
||||
expect(build()).toBe(
|
||||
[
|
||||
'<div class="chatwoot_forward_header">',
|
||||
'---------- Forwarded message ---------<br>',
|
||||
'From: <b>Jane Doe</b> <<a href="mailto:jane@example.com">jane@example.com</a>><br>',
|
||||
'Date: Thu, Sep 17, 2026 at 9:39 AM<br>',
|
||||
'Subject: Order #42<br>',
|
||||
'To: <<a href="mailto:support@example.com">support@example.com</a>>',
|
||||
'</div><br><p>Original body</p>',
|
||||
].join('')
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the bold name when the sender has none', () => {
|
||||
expect(build({ sender: { email: 'jane@example.com' } })).toContain(
|
||||
'From: <<a href="mailto:jane@example.com">jane@example.com</a>><br>'
|
||||
);
|
||||
});
|
||||
|
||||
it('lists every recipient', () => {
|
||||
expect(
|
||||
build({ recipients: ['a@example.com', 'b@example.com'] })
|
||||
).toContain(
|
||||
'To: <<a href="mailto:a@example.com">a@example.com</a>>, <<a href="mailto:b@example.com">b@example.com</a>>'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes header values but keeps the body html intact', () => {
|
||||
const html = build({
|
||||
sender: { name: '<script>x</script>', email: 'a"b@example.com' },
|
||||
subject: 'Re: <img src=x onerror=alert(1)>',
|
||||
bodyHtml: '<p>Keep <b>this</b></p>',
|
||||
});
|
||||
|
||||
expect(html).toContain('<b><script>x</script></b>');
|
||||
expect(html).toContain('mailto:a"b@example.com');
|
||||
expect(html).toContain('Subject: Re: <img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('<p>Keep <b>this</b></p>');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -378,6 +378,7 @@
|
||||
"CONTEXT_MENU": {
|
||||
"COPY": "Copy",
|
||||
"REPLY_TO": "Reply to this message",
|
||||
"FORWARD_EMAIL": "Forward email",
|
||||
"DELETE": "Delete",
|
||||
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
|
||||
"TRANSLATE": "Translate",
|
||||
@@ -555,8 +556,17 @@
|
||||
"BCC": "Bcc",
|
||||
"CC": "Cc",
|
||||
"SUBJECT": "Subject",
|
||||
"DATE": "Date",
|
||||
"EXPAND": "Expand email"
|
||||
},
|
||||
"FORWARD_EMAIL": {
|
||||
"HEADER_TITLE": "---------- Forwarded message ---------",
|
||||
"TO_LABEL": "To:",
|
||||
"TO_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"MESSAGE_PLACEHOLDER": "Add a message (optional)",
|
||||
"FORWARDED_TO": "Forwarded to {emails}",
|
||||
"CONTACT_CANNOT_SEE": "{name} cannot see this"
|
||||
},
|
||||
"CONVERSATION_PARTICIPANTS": {
|
||||
"SIDEBAR_MENU_TITLE": "Participating",
|
||||
"SIDEBAR_TITLE": "Conversation participants",
|
||||
|
||||
@@ -47,7 +47,7 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['open', 'close', 'replyTo'],
|
||||
emits: ['open', 'close', 'replyTo', 'forwardEmail'],
|
||||
setup() {
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
@@ -140,6 +140,10 @@ export default {
|
||||
this.$emit('replyTo', this.message);
|
||||
this.handleClose();
|
||||
},
|
||||
handleForwardEmail() {
|
||||
this.$emit('forwardEmail');
|
||||
this.handleClose();
|
||||
},
|
||||
openDeleteModal() {
|
||||
this.handleClose();
|
||||
this.showDeleteModal = true;
|
||||
@@ -208,6 +212,15 @@ export default {
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="menu-container">
|
||||
<MenuItem
|
||||
v-if="enabledOptions['forwardEmail']"
|
||||
:option="{
|
||||
icon: 'arrow-forward',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.FORWARD_EMAIL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="handleForwardEmail"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="enabledOptions['replyTo']"
|
||||
:option="{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { withFullI18n } from 'test-i18n';
|
||||
import MessageContextMenu from '../MessageContextMenu.vue';
|
||||
|
||||
withFullI18n();
|
||||
|
||||
const mountMenu = enabledOptions =>
|
||||
mount(MessageContextMenu, {
|
||||
props: {
|
||||
message: { id: 1, content: 'Hello', conversation_id: 2 },
|
||||
isOpen: true,
|
||||
enabledOptions,
|
||||
contextMenuPosition: { x: 10, y: 20 },
|
||||
hideButton: true,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'accounts/getAccount': () => ({}),
|
||||
getCurrentAccountId: 1,
|
||||
getUISettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
ContextMenu: { template: '<div><slot /></div>' },
|
||||
AddCannedModal: true,
|
||||
ReportCaptainMessageDialog: true,
|
||||
NextButton: true,
|
||||
FluentIcon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const menuItem = (wrapper, label) =>
|
||||
wrapper.findAll('.menu').find(item => item.text() === label);
|
||||
|
||||
describe('MessageContextMenu', () => {
|
||||
it('offers forward email first and emits it on click', async () => {
|
||||
const wrapper = mountMenu({ forwardEmail: true, copy: true });
|
||||
const labels = wrapper.findAll('.menu').map(item => item.text());
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(['Forward email', 'Copy']);
|
||||
|
||||
await menuItem(wrapper, 'Forward email').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('forwardEmail')).toHaveLength(1);
|
||||
expect(wrapper.emitted('close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hides forward email when it is not enabled', () => {
|
||||
const wrapper = mountMenu({ copy: true });
|
||||
|
||||
expect(menuItem(wrapper, 'Forward email')).toBeUndefined();
|
||||
expect(menuItem(wrapper, 'Copy')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -64,8 +64,13 @@ const getters = {
|
||||
const selectedChat = _getters.getSelectedChat;
|
||||
const { messages = [] } = selectedChat;
|
||||
const lastEmail = [...messages].reverse().find(message => {
|
||||
const { message_type: messageType } = message;
|
||||
if (message.private) return false;
|
||||
const {
|
||||
message_type: messageType,
|
||||
content_attributes: contentAttributes,
|
||||
} = message;
|
||||
if (message.private || contentAttributes?.forwarded_message_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [MESSAGE_TYPE.OUTGOING, MESSAGE_TYPE.INCOMING].includes(
|
||||
messageType
|
||||
|
||||
@@ -333,6 +333,29 @@ describe('#getters', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Skips forwarded emails', () => {
|
||||
const state = {};
|
||||
const incomingEmail = {
|
||||
message_type: 0,
|
||||
content_attributes: { email: { from: 'why@how.my' } },
|
||||
};
|
||||
const getSelectedChat = {
|
||||
messages: [
|
||||
incomingEmail,
|
||||
{
|
||||
message_type: 1,
|
||||
content_attributes: {
|
||||
forwarded_message_id: 1,
|
||||
to_emails: ['vendor@example.com'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
getters.getLastEmailInSelectedChat(state, { getSelectedChat })
|
||||
).toEqual(incomingEmail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSelectedChatAttachments', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"arrow-outwards-outline": "m16 8.4l-8.875 8.9q-.3.3-.713.3t-.712-.3q-.3-.3-.3-.713t.3-.712L14.6 7H7q-.425 0-.713-.288T6 6q0-.425.288-.713T7 5h10q.425 0 .713.288T18 6v10q0 .425-.288.713T17 17q-.425 0-.713-.288T16 16V8.4Z",
|
||||
"arrow-redo-outline": "M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z",
|
||||
"arrow-right-import-outline": "M21.25 4.5a.75.75 0 0 1 .743.648L22 5.25v13.004a.75.75 0 0 1-1.493.102l-.007-.102V5.25a.75.75 0 0 1 .75-.75Zm-8.603 1.804l.072-.084a.75.75 0 0 1 .977-.073l.084.073l4.997 4.997a.75.75 0 0 1 .073.976l-.073.085l-4.997 5.003a.75.75 0 0 1-1.133-.976l.072-.084l3.711-3.717H2.75a.75.75 0 0 1-.743-.647L2 11.755a.75.75 0 0 1 .648-.743l.102-.007l13.693-.001l-3.724-3.724a.75.75 0 0 1-.072-.976l.072-.084l-.072.084Z",
|
||||
"arrow-forward-outline": "M14.72 6.28a.75.75 0 0 1 1.06-1.06l5 5a.75.75 0 0 1 0 1.06l-5 5a.75.75 0 1 1-1.06-1.06l3.72-3.72h-7.69a6.25 6.25 0 0 0-6.25 6.25v.5a.75.75 0 0 1-1.5 0v-.5A7.75 7.75 0 0 1 10.75 10h7.69z",
|
||||
"arrow-reply-outline": "M9.277 16.221a.75.75 0 0 1-1.061 1.06l-4.997-5.003a.75.75 0 0 1 0-1.06L8.217 6.22a.75.75 0 0 1 1.061 1.06L5.557 11h7.842c1.595 0 2.81.242 3.889.764l.246.126a6.203 6.203 0 0 1 2.576 2.576c.61 1.14.89 2.418.89 4.135a.75.75 0 0 1-1.5 0c0-1.484-.228-2.52-.713-3.428a4.702 4.702 0 0 0-1.96-1.96c-.838-.448-1.786-.676-3.094-.709L13.4 12.5H5.562l3.715 3.721Z",
|
||||
"arrow-right-outline": "M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z",
|
||||
"arrow-sort-outline": "m6.288 4.293-3.995 4-.084.095a1 1 0 0 0 .084 1.32l.095.083a1 1 0 0 0 1.32-.084L6 7.41V19l.007.117a1 1 0 0 0 .993.884l.117-.007A1 1 0 0 0 8 19V7.417l2.293 2.29.095.084a1 1 0 0 0 1.319-1.499l-4.006-4-.094-.083a1 1 0 0 0-1.32.084ZM17 4.003l-.117.007a1 1 0 0 0-.883.993v11.58l-2.293-2.29-.095-.084a1 1 0 0 0-1.319 1.498l4.004 4 .094.084a1 1 0 0 0 1.32-.084l3.996-4 .084-.095a1 1 0 0 0-.084-1.32l-.095-.083a1 1 0 0 0-1.32.084L18 16.587V5.003l-.007-.116A1 1 0 0 0 17 4.003Z",
|
||||
|
||||
@@ -39,15 +39,14 @@ class Imap::ImapMailbox
|
||||
@processed_mail = MailPresenter.new(@inbound_mail, @account)
|
||||
end
|
||||
|
||||
# Replies to a forwarded email stay in the forward recipient's own conversation
|
||||
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.find_by("additional_attributes->>'in_reply_to' = ?", in_reply_to)
|
||||
else
|
||||
@inbox.conversations.find(message.conversation_id)
|
||||
end
|
||||
return @inbox.conversations.find(message.conversation_id) if message.present? && !message.forwarded?
|
||||
|
||||
@contact.conversations.where(inbox: @inbox).find_by("additional_attributes->>'in_reply_to' = ?", in_reply_to)
|
||||
end
|
||||
|
||||
def find_conversation_by_reference_ids
|
||||
@@ -81,7 +80,7 @@ class Imap::ImapMailbox
|
||||
|
||||
references.each do |message_id|
|
||||
message = @inbox.messages.find_by(source_id: message_id)
|
||||
message_to_return = message if message.present?
|
||||
message_to_return = message if message.present? && !message.forwarded?
|
||||
end
|
||||
message_to_return
|
||||
end
|
||||
|
||||
@@ -120,15 +120,11 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def mail_subject
|
||||
subject = @conversation.additional_attributes['mail_subject']
|
||||
subject = @message&.forwarded? ? forwarded_subject : @conversation.additional_attributes['mail_subject']
|
||||
return "[##{@conversation.display_id}] #{I18n.t('conversations.reply.email_subject')}" if subject.nil?
|
||||
return "Fwd: #{subject}" if @message&.forwarded?
|
||||
|
||||
chat_count = @conversation.messages.chat.count
|
||||
if chat_count > 1
|
||||
"Re: #{subject}"
|
||||
else
|
||||
subject
|
||||
end
|
||||
@conversation.messages.chat.count > 1 ? "Re: #{subject}" : subject
|
||||
end
|
||||
|
||||
def reply_email
|
||||
@@ -155,11 +151,14 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
|
||||
def custom_message_id
|
||||
last_message = @message || @messages&.last
|
||||
return "<forward/#{last_message.id}@#{channel_email_domain}>" if last_message&.forwarded?
|
||||
|
||||
"<conversation/#{@conversation.uuid}/messages/#{last_message&.id}@#{channel_email_domain}>"
|
||||
end
|
||||
|
||||
def in_reply_to_email
|
||||
return if @message&.forwarded?
|
||||
|
||||
conversation_reply_email_id || "<account/#{@account.id}/conversation/#{@conversation.uuid}@#{channel_email_domain}>"
|
||||
end
|
||||
|
||||
@@ -175,7 +174,12 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def references_header
|
||||
build_references_header(@conversation, in_reply_to_email)
|
||||
build_references_header(@conversation, in_reply_to_email).presence
|
||||
end
|
||||
|
||||
def forwarded_subject
|
||||
forwarded_message = @conversation.messages.find_by(id: @message.content_attributes['forwarded_message_id'])
|
||||
forwarded_message&.content_attributes&.dig('email', 'subject').presence || @conversation.additional_attributes['mail_subject']
|
||||
end
|
||||
|
||||
def cc_bcc_emails
|
||||
|
||||
@@ -107,7 +107,9 @@ module ConversationReplyMailerHelper
|
||||
def email_reply_to
|
||||
return Email::ReplyToBuilder.new(inbox: @inbox, message: current_message).build if @account.feature_enabled?(:reply_mailer_migration)
|
||||
|
||||
email_imap_enabled? ? @channel.email : reply_email
|
||||
return @channel.email if email_imap_enabled? || @message&.forwarded?
|
||||
|
||||
reply_email
|
||||
end
|
||||
|
||||
# Use channel email domain in case of account email domain is not set for custom message_id and in_reply_to
|
||||
|
||||
@@ -18,7 +18,7 @@ module MessageFilterHelpers
|
||||
end
|
||||
|
||||
def conversation_transcriptable?
|
||||
incoming? || outgoing?
|
||||
(incoming? || outgoing?) && !forwarded?
|
||||
end
|
||||
|
||||
def email_reply_summarizable?
|
||||
|
||||
@@ -119,6 +119,7 @@ class Message < ApplicationRecord
|
||||
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
|
||||
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('created_at desc') }
|
||||
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
|
||||
scope :not_forwarded, -> { where("(messages.content_attributes #>> '{}')::jsonb -> 'forwarded_message_id' IS NULL") }
|
||||
scope :voice_calls, -> { where(content_type: :voice_call) }
|
||||
|
||||
# TODO: Get rid of default scope
|
||||
@@ -216,6 +217,10 @@ class Message < ApplicationRecord
|
||||
true
|
||||
end
|
||||
|
||||
def forwarded?
|
||||
content_attributes['forwarded_message_id'].present?
|
||||
end
|
||||
|
||||
def auto_reply_email?
|
||||
return false unless incoming_email? || inbox.email?
|
||||
|
||||
@@ -228,6 +233,7 @@ class Message < ApplicationRecord
|
||||
return false if conversation.messages.outgoing
|
||||
.where.not(sender_type: ['AgentBot', 'Captain::Assistant'])
|
||||
.where.not(private: true)
|
||||
.not_forwarded
|
||||
.where("(additional_attributes->'campaign_id') is null").count > 1
|
||||
|
||||
true
|
||||
@@ -365,7 +371,9 @@ class Message < ApplicationRecord
|
||||
# if automation rule id is present, it's not a human response
|
||||
# if campaign id is present, it's not a human response
|
||||
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
|
||||
# forwarded emails go to a third party, not to the contact
|
||||
outgoing? &&
|
||||
!forwarded? &&
|
||||
content_attributes['automation_rule_id'].blank? &&
|
||||
additional_attributes['campaign_id'].blank? &&
|
||||
(sender.is_a?(User) || content_attributes['external_echo'].present?)
|
||||
|
||||
@@ -9,7 +9,7 @@ class Captain::ConversationTranscript
|
||||
# The single rule for what Captain may read: public customer and agent messages that still have content,
|
||||
# cut to the characters the caller has room for.
|
||||
def self.entry(message, limit)
|
||||
return unless MESSAGE_TYPES.include?(message.message_type) && !message.private? && !message.deleted
|
||||
return unless MESSAGE_TYPES.include?(message.message_type) && !message.private? && !message.deleted && !message.forwarded?
|
||||
|
||||
text = message.content_for_llm
|
||||
{ sender: message.incoming? ? 'customer' : 'agent', text: text[0, limit] } if text.present?
|
||||
@@ -22,6 +22,7 @@ class Captain::ConversationTranscript
|
||||
|
||||
conversation.messages
|
||||
.where(message_type: MESSAGE_TYPES, private: false)
|
||||
.not_forwarded
|
||||
.reorder(id: :desc)
|
||||
.limit(MESSAGE_LIMIT)
|
||||
.each do |message|
|
||||
|
||||
@@ -27,7 +27,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
def build_messages(config = {})
|
||||
return "No messages in this conversation\n" if @record.messages.empty?
|
||||
|
||||
messages = @record.messages.where.not(message_type: [:activity, :template])
|
||||
messages = @record.messages.where.not(message_type: [:activity, :template]).not_forwarded
|
||||
|
||||
if config[:token_limit]
|
||||
build_limited_messages(messages, config)
|
||||
|
||||
@@ -25,8 +25,8 @@ class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::Conver
|
||||
end
|
||||
|
||||
# Try finding by message source_id
|
||||
message = Message.find_by(source_id: in_reply_to)
|
||||
return message.conversation if message&.conversation
|
||||
conversation = conversation_from_source_id(in_reply_to)
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
nil
|
||||
@@ -34,6 +34,12 @@ class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::Conver
|
||||
|
||||
private
|
||||
|
||||
# Replies to a forwarded email start a new conversation with the forward recipient
|
||||
def conversation_from_source_id(message_id)
|
||||
message = Message.find_by(source_id: message_id)
|
||||
message&.conversation unless message&.forwarded?
|
||||
end
|
||||
|
||||
def extract_uuid_from_patterns(message_id)
|
||||
# Try message-specific pattern first
|
||||
match = MESSAGE_PATTERN.match(message_id)
|
||||
|
||||
@@ -24,13 +24,9 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
|
||||
return nil unless @channel # No valid channel found
|
||||
return nil unless incoming_email_from_valid_email? # Skip edge cases
|
||||
|
||||
# Check if conversation already exists by in_reply_to
|
||||
existing_conversation = find_conversation_by_in_reply_to
|
||||
return existing_conversation if existing_conversation
|
||||
|
||||
# Prepare contact (persisted) and build conversation (not persisted)
|
||||
# Prepare contact (persisted), reuse the sender's conversation for the thread or build one (not persisted)
|
||||
find_or_create_contact
|
||||
build_conversation
|
||||
find_conversation_by_in_reply_to || build_conversation
|
||||
end
|
||||
|
||||
private
|
||||
@@ -75,9 +71,10 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
|
||||
sanitize_mailbox_value(mail['In-Reply-To'].try(:value))
|
||||
end
|
||||
|
||||
# Scoped to the sender so replies from different recipients of the same forward stay separate
|
||||
def find_conversation_by_in_reply_to
|
||||
return if in_reply_to.blank?
|
||||
|
||||
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
|
||||
@contact.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,8 +43,9 @@ class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::Conve
|
||||
end
|
||||
|
||||
# We scope to the inbox, that way we filter out messages and conversations that don't belong to the channel
|
||||
# Replies to a forwarded email start a new conversation with the forward recipient
|
||||
message = Message.find_by(source_id: reference, inbox_id: @channel.inbox.id)
|
||||
message&.conversation
|
||||
message&.conversation unless message&.forwarded?
|
||||
end
|
||||
|
||||
def extract_uuid_from_patterns(message_id)
|
||||
|
||||
@@ -18,6 +18,7 @@ class Captain::Conversation::MessageHistoryBuilderService
|
||||
def conversation_messages_for_context
|
||||
conversation.messages
|
||||
.where(private: false, message_type: [:incoming, :outgoing, :activity])
|
||||
.not_forwarded
|
||||
.reorder(created_at: :asc, id: :asc)
|
||||
end
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class Captain::ConversationOutcomeTracker
|
||||
end
|
||||
|
||||
def public_human_reply?(message)
|
||||
return false unless message.outgoing? && !message.private?
|
||||
return false unless message.outgoing? && !message.private? && !message.forwarded?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class Captain::Llm::ConversationFaqContentService
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.not_forwarded
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
@@ -52,6 +52,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
conversation.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.not_forwarded
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
|
||||
@@ -178,6 +178,17 @@ describe Messages::MessageBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarding a message in a non email inbox' do
|
||||
let(:params) do
|
||||
ActionController::Parameters.new({ content: 'test', to_emails: 'vendor@example.com',
|
||||
content_attributes: { forwarded_message_id: message_for_reply.id }.to_json })
|
||||
end
|
||||
|
||||
it 'rejects the message' do
|
||||
expect { message_builder }.to raise_error 'Forwarded emails need an email inbox'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email channel messages' do
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:inbox_member) { create(:inbox_member, inbox: channel_email.inbox) }
|
||||
@@ -209,6 +220,69 @@ describe Messages::MessageBuilder do
|
||||
expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
|
||||
end
|
||||
|
||||
context 'when forwarding a message' do
|
||||
let(:forwarded_message) { create(:message, conversation: conversation, account: account, message_type: :incoming) }
|
||||
let(:forwarded_attachment) do
|
||||
attachment = forwarded_message.attachments.new(account_id: account.id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
let(:params) do
|
||||
ActionController::Parameters.new({
|
||||
content: 'Forwarding this',
|
||||
private: 'false',
|
||||
to_emails: 'vendor@example.com',
|
||||
email_html_content: '<p>Forwarding this</p><p>Edited body</p>',
|
||||
content_attributes: { forwarded_message_id: forwarded_message.id }.to_json,
|
||||
forwarded_attachment_ids: [forwarded_attachment.id]
|
||||
})
|
||||
end
|
||||
|
||||
it 'copies the selected attachments of the forwarded message' do
|
||||
message = message_builder
|
||||
|
||||
expect(message.content_attributes[:forwarded_message_id]).to eq forwarded_message.id
|
||||
expect(message.content_attributes[:to_emails]).to eq ['vendor@example.com']
|
||||
expect(message.attachments.map { |attachment| attachment.file.blob }).to eq [forwarded_attachment.file.blob]
|
||||
end
|
||||
|
||||
it 'keeps the edited html when the private flag arrives as a multipart string' do
|
||||
message = message_builder
|
||||
|
||||
expect(message.private).to be(false)
|
||||
expect(message.content_attributes.dig(:email, :html_content, :reply)).to eq('<p>Forwarding this</p><p>Edited body</p>')
|
||||
end
|
||||
|
||||
it 'rejects a private forward' do
|
||||
params[:private] = true
|
||||
|
||||
expect { message_builder }.to raise_error 'Forwarded emails cannot be private'
|
||||
end
|
||||
|
||||
it 'ignores attachments that do not belong to the forwarded message' do
|
||||
other_message = create(:message, conversation: conversation, account: account)
|
||||
other_attachment = other_message.attachments.new(account_id: account.id, file_type: :image)
|
||||
other_attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
other_attachment.save!
|
||||
params[:forwarded_attachment_ids] = [other_attachment.id]
|
||||
|
||||
expect(message_builder.attachments).to be_empty
|
||||
end
|
||||
|
||||
it 'raises when the forwarded message belongs to another conversation' do
|
||||
params[:content_attributes] = { forwarded_message_id: create(:message, account: account).id }.to_json
|
||||
|
||||
expect { message_builder }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'raises when no recipient is given' do
|
||||
params[:to_emails] = ''
|
||||
|
||||
expect { message_builder }.to raise_error(StandardError, 'Forwarded emails need a recipient')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom email content is provided' do
|
||||
it 'creates message with custom HTML email content' do
|
||||
params = ActionController::Parameters.new({
|
||||
|
||||
@@ -98,6 +98,63 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(conversation.messages.last.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
|
||||
context 'when forwarding an email' do
|
||||
let(:inbox) { create(:channel_email, account: account).inbox }
|
||||
let(:forwarded_message) { create(:message, conversation: conversation, account: account, message_type: :incoming) }
|
||||
let(:forwarded_attachment) do
|
||||
attachment = forwarded_message.attachments.new(account_id: account.id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
it 'creates the forward with the edited html, recipients and reused attachments' do
|
||||
params = {
|
||||
content: 'Please handle this',
|
||||
to_emails: 'vendor@example.com',
|
||||
email_html_content: '<p>Please handle this</p><p>Edited body</p>',
|
||||
content_attributes: { forwarded_message_id: forwarded_message.id }.to_json,
|
||||
forwarded_attachment_ids: [forwarded_attachment.id]
|
||||
}
|
||||
|
||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
forward = conversation.messages.outgoing.last
|
||||
expect(forward.content).to eq('Please handle this')
|
||||
expect(forward.content_attributes['forwarded_message_id']).to eq(forwarded_message.id)
|
||||
expect(forward.content_attributes['to_emails']).to eq(['vendor@example.com'])
|
||||
expect(forward.content_attributes.dig('email', 'html_content', 'reply')).to eq('<p>Please handle this</p><p>Edited body</p>')
|
||||
expect(forward.attachments.map { |attachment| attachment.file.blob }).to eq([forwarded_attachment.file.blob])
|
||||
end
|
||||
|
||||
it 'rejects a forwarded message that belongs to another conversation' do
|
||||
other_message = create(:message, account: account)
|
||||
|
||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: { content: 'Please handle this', content_attributes: { forwarded_message_id: other_message.id } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('Couldn\'t find Message')
|
||||
expect(conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
|
||||
it 'rejects a forward without a recipient' do
|
||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: { content: 'Please handle this', content_attributes: { forwarded_message_id: forwarded_message.id } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Forwarded emails need a recipient')
|
||||
expect(conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when api inbox' do
|
||||
let(:api_channel) { create(:channel_api, account: account) }
|
||||
let(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
|
||||
@@ -241,6 +298,16 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(message.reload.content_attributes['bcc_emails']).to be_nil
|
||||
end
|
||||
|
||||
it 'keeps the forward marker on a deleted forwarded email' do
|
||||
message.update!(content_attributes: { forwarded_message_id: 1, to_emails: ['vendor@example.com'] })
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/messages/#{message.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(message.reload.content_attributes).to eq('forwarded_message_id' => 1, 'deleted' => true)
|
||||
end
|
||||
|
||||
it 'deletes interactive messages' do
|
||||
interactive_message = create(
|
||||
:message, message_type: :outgoing, content: 'test', content_type: 'input_select',
|
||||
@@ -275,7 +342,9 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/conversations/:conversation_id/messages/:id/retry' do
|
||||
let(:message) { create(:message, account: account, status: :failed, content_attributes: { external_error: 'error' }) }
|
||||
let(:message) do
|
||||
create(:message, account: account, status: :failed, content_attributes: { external_error: 'error', to_emails: ['vendor@example.com'] })
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -291,14 +360,15 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
create(:inbox_member, inbox: message.conversation.inbox, user: agent)
|
||||
end
|
||||
|
||||
it 'retries the message' do
|
||||
it 'retries the message and keeps its delivery details' do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{message.conversation.display_id}/messages/#{message.id}/retry",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(message.reload.status).to eq('sent')
|
||||
expect(message.reload.content_attributes['external_error']).to be_nil
|
||||
expect(message.content_attributes['external_error']).to be_nil
|
||||
expect(message.content_attributes['to_emails']).to eq(['vendor@example.com'])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
)
|
||||
create(:message, conversation: conversation, message_type: :activity, content: 'Assigned to agent', created_at: same_second,
|
||||
updated_at: same_second)
|
||||
create(:message, conversation: conversation, content: 'Note to supplier', message_type: :outgoing,
|
||||
content_attributes: { forwarded_message_id: 1 }, created_at: same_second, updated_at: same_second)
|
||||
create(:message, conversation: conversation, content: 'Fresh question', message_type: :incoming, created_at: same_second,
|
||||
updated_at: same_second)
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ RSpec.describe Captain::ConversationOutcomeTracker do
|
||||
sender: agent, message_type: :outgoing, created_at: 15.minutes.ago,
|
||||
content_attributes: { automation_rule_id: 1 }
|
||||
)
|
||||
create(
|
||||
:message,
|
||||
account: account, inbox: inbox, conversation: conversation,
|
||||
sender: agent, message_type: :outgoing, created_at: 12.minutes.ago,
|
||||
content_attributes: { forwarded_message_id: 1 }
|
||||
)
|
||||
human_reply = create(
|
||||
:message,
|
||||
account: account, inbox: inbox, conversation: conversation,
|
||||
|
||||
@@ -190,6 +190,35 @@ RSpec.describe Imap::ImapMailbox do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a reply to a forwarded email' do
|
||||
let(:prev_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
|
||||
let(:supplier_reply) do
|
||||
create_inbound_email_from_mail(from: 'supplier@example.com', to: 'imap@gmail.com', subject: 'Re: Fwd: Hello!',
|
||||
in_reply_to: '<forward/1@example.com>')
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: prev_conversation, account: account, inbox: inbox, message_type: :outgoing,
|
||||
source_id: 'forward/1@example.com', content_attributes: { forwarded_message_id: 1, to_emails: ['supplier@example.com'] })
|
||||
end
|
||||
|
||||
it 'starts a new conversation with the forward recipient instead of using the original conversation' do
|
||||
expect { class_instance.process(supplier_reply.mail, channel) }.to change(Conversation, :count).by(1)
|
||||
|
||||
expect(prev_conversation.messages.incoming).to be_empty
|
||||
expect(conversation.contact.email).to eq('supplier@example.com')
|
||||
end
|
||||
|
||||
it 'keeps further replies from the same recipient in their conversation' do
|
||||
class_instance.process(supplier_reply.mail, channel)
|
||||
second_reply = create_inbound_email_from_mail(from: 'supplier@example.com', to: 'imap@gmail.com', subject: 'Re: Fwd: Hello!',
|
||||
in_reply_to: '<forward/1@example.com>')
|
||||
|
||||
expect { class_instance.process(second_reply.mail, channel) }.not_to change(Conversation, :count)
|
||||
expect(conversation.messages.incoming.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a new conversation with nil in_reply_to' do
|
||||
let(:prev_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
|
||||
let(:reply_mail) do
|
||||
|
||||
@@ -67,6 +67,39 @@ RSpec.describe ReplyMailbox do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the mail replies to a forwarded email' do
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account) }
|
||||
let(:supplier_reply) do
|
||||
create_inbound_email_from_mail(from: 'supplier@example.com', to: email_channel.email, subject: 'Re: Fwd: Hello',
|
||||
in_reply_to: '<forward/1@example.com>', body: 'Reply from the supplier')
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, account: account, message_type: :outgoing, source_id: 'forward/1@example.com',
|
||||
content_attributes: { forwarded_message_id: 1, to_emails: ['supplier@example.com'] })
|
||||
end
|
||||
|
||||
it 'starts a new conversation with the forward recipient instead of using the original conversation' do
|
||||
expect { described_class.receive supplier_reply }.to change(account.conversations, :count).by(1)
|
||||
|
||||
expect(conversation.messages.incoming).to be_empty
|
||||
expect(account.conversations.last.contact.email).to eq('supplier@example.com')
|
||||
expect(account.conversations.last.messages.last.content).to include('Reply from the supplier')
|
||||
end
|
||||
|
||||
it 'keeps replies from different forward recipients in separate conversations' do
|
||||
described_class.receive supplier_reply
|
||||
vendor_reply = create_inbound_email_from_mail(from: 'vendor@example.com', to: email_channel.email, subject: 'Re: Fwd: Hello',
|
||||
in_reply_to: '<forward/1@example.com>', body: 'Reply from the vendor')
|
||||
|
||||
expect { described_class.receive vendor_reply }.to change(account.conversations, :count).by(1)
|
||||
|
||||
expect(account.conversations.last.contact.email).to eq('vendor@example.com')
|
||||
expect(account.conversations.find_by(contact: Contact.from_email('supplier@example.com')).messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when new conversation email contains null bytes' do
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:null_byte_mail) { create_inbound_email_from_mail(from: 'sender@example.com', to: email_channel.email, subject: 'Hello') }
|
||||
|
||||
@@ -450,6 +450,56 @@ RSpec.describe ConversationReplyMailer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarding an email' do
|
||||
let(:forwarded_message) do
|
||||
create(:message, conversation: conversation, account: account, message_type: 'incoming',
|
||||
content_attributes: { email: { message_id: 'original@example.com' } })
|
||||
end
|
||||
let(:forward) do
|
||||
create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Forwarding this',
|
||||
content_attributes: { forwarded_message_id: forwarded_message.id, to_emails: ['vendor@example.com'] })
|
||||
end
|
||||
let(:mail) { described_class.email_reply(forward).deliver_now }
|
||||
|
||||
before do
|
||||
conversation.additional_attributes = { 'mail_subject': 'Mail Subject' }
|
||||
conversation.save!
|
||||
end
|
||||
|
||||
it 'sends to the forward recipients with a forward subject and no threading headers' do
|
||||
expect(mail.to).to eq ['vendor@example.com']
|
||||
expect(mail.subject).to eq 'Fwd: Mail Subject'
|
||||
expect(mail.in_reply_to).to be_nil
|
||||
expect(mail.references).to be_nil
|
||||
end
|
||||
|
||||
it 'uses the subject of the forwarded message when it has one' do
|
||||
forwarded_message.update!(content_attributes: { email: { message_id: 'original@example.com', subject: 'Changed subject' } })
|
||||
|
||||
expect(mail.subject).to eq 'Fwd: Changed subject'
|
||||
end
|
||||
|
||||
it 'uses the subject of the forwarded message when the conversation has none' do
|
||||
conversation.update!(additional_attributes: {})
|
||||
forwarded_message.update!(content_attributes: { email: { message_id: 'original@example.com', subject: 'Later subject' } })
|
||||
|
||||
expect(mail.subject).to eq 'Fwd: Later subject'
|
||||
end
|
||||
|
||||
it 'keeps replies from the forward recipient out of the conversation' do
|
||||
expect(mail.message_id).to start_with("forward/#{forward.id}@")
|
||||
expect(mail.message_id).not_to include(conversation.uuid)
|
||||
expect(mail.reply_to).to eq [email_channel.email]
|
||||
end
|
||||
|
||||
it 'leaves the forward out of the conversation transcript' do
|
||||
forward
|
||||
transcript = described_class.conversation_transcript(conversation, 'customer@example.com').deliver_now
|
||||
|
||||
expect(transcript.decoded).not_to include('Forwarding this')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom email content' do
|
||||
it 'uses custom HTML content when available and creates multipart email' do
|
||||
message_with_custom_content = create(:message,
|
||||
|
||||
@@ -171,6 +171,20 @@ RSpec.describe Message do
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
|
||||
it 'does not count a forwarded email as a reply to the contact' do
|
||||
create(:message, message_type: :outgoing, conversation: conversation, content_attributes: { forwarded_message_id: 1 })
|
||||
|
||||
expect(conversation.first_reply_created_at).to be_nil
|
||||
expect(conversation.waiting_since).to eq conversation.created_at
|
||||
end
|
||||
|
||||
it 'records the first reply to the contact after a forwarded email' do
|
||||
create(:message, message_type: :outgoing, conversation: conversation, content_attributes: { forwarded_message_id: 1 })
|
||||
reply = create(:message, message_type: :outgoing, conversation: conversation)
|
||||
|
||||
expect(conversation.first_reply_created_at).to eq reply.created_at
|
||||
end
|
||||
|
||||
it 'does not update the conversation first reply created at if the message is incoming' do
|
||||
expect(conversation.first_reply_created_at).to be_nil
|
||||
expect(conversation.waiting_since).to eq conversation.created_at
|
||||
|
||||
@@ -45,6 +45,12 @@ RSpec.describe Captain::ConversationTranscript do
|
||||
expect(described_class.entry(message, 100)).to be_nil
|
||||
end
|
||||
|
||||
it 'is nil for a forwarded email' do
|
||||
message = create_message(message_type: :outgoing, content: 'Note to supplier', content_attributes: { forwarded_message_id: 1 })
|
||||
|
||||
expect(described_class.entry(message, 100)).to be_nil
|
||||
end
|
||||
|
||||
it 'is nil for a message without content' do
|
||||
message = create_message(message_type: :incoming, content: nil)
|
||||
|
||||
@@ -57,6 +63,7 @@ RSpec.describe Captain::ConversationTranscript do
|
||||
create_message(message_type: :incoming, content: 'I want my money back')
|
||||
create_message(message_type: :outgoing, private: true, content: 'Customer is on the legacy plan')
|
||||
create_message(message_type: :template, content: 'Give the team a way to reach you.')
|
||||
create_message(message_type: :outgoing, content: 'Note to supplier', content_attributes: { forwarded_message_id: 1 })
|
||||
create_message(message_type: :outgoing, content: 'Let me check that for you')
|
||||
|
||||
expect(described_class.new(conversation: conversation).messages).to eq(
|
||||
|
||||
@@ -8,6 +8,59 @@ describe Email::SendOnEmailService do
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
|
||||
let(:service) { described_class.new(message: message) }
|
||||
|
||||
describe '#perform with a forwarded email', type: :mailer do
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:mailer) { ConversationReplyMailer.new }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, additional_attributes: { 'mail_subject' => 'Order #42' })
|
||||
end
|
||||
let(:forwarded_message) { create(:message, conversation: conversation, message_type: 'incoming') }
|
||||
let(:forwarded_attachment) do
|
||||
attachment = forwarded_message.attachments.new(account_id: account.id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
let(:params) do
|
||||
ActionController::Parameters.new(
|
||||
content: 'Please handle this',
|
||||
to_emails: 'vendor@example.com',
|
||||
cc_emails: 'ops@example.com',
|
||||
bcc_emails: 'audit@example.com',
|
||||
email_html_content: '<p>Please handle this</p><p>Edited body</p>',
|
||||
content_attributes: { forwarded_message_id: forwarded_message.id }.to_json,
|
||||
forwarded_attachment_ids: [forwarded_attachment.id]
|
||||
)
|
||||
end
|
||||
let(:message) { Messages::MessageBuilder.new(agent, conversation, params).perform }
|
||||
|
||||
before do
|
||||
allow(ConversationReplyMailer).to receive(:new).and_return(mailer)
|
||||
allow(mailer).to receive(:smtp_config_set_or_development?).and_return(true)
|
||||
end
|
||||
|
||||
it 'delivers the forward to the new recipient as a fresh thread' do
|
||||
expect { service.perform }.to change { ActionMailer::Base.deliveries.count }.by(1)
|
||||
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
expect(mail.to).to eq(['vendor@example.com'])
|
||||
expect(mail.cc).to eq(['ops@example.com'])
|
||||
expect(mail.bcc).to eq(['audit@example.com'])
|
||||
expect(mail.subject).to eq('Fwd: Order #42')
|
||||
expect(mail.in_reply_to).to be_nil
|
||||
expect(mail.references).to be_nil
|
||||
end
|
||||
|
||||
it 'sends the edited body with the original attachment and records the source id' do
|
||||
service.perform
|
||||
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
expect(mail.html_part.decoded).to include('<p>Please handle this</p><p>Edited body</p>')
|
||||
expect(mail.attachments.map(&:filename)).to eq(['avatar.png'])
|
||||
expect(message.reload.source_id).to eq(mail.message_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:mailer_context) { instance_double(ConversationReplyMailer) }
|
||||
let(:delivery) { instance_double(ActionMailer::MessageDelivery) }
|
||||
|
||||
@@ -53,6 +53,25 @@ RSpec.describe Mailbox::ConversationFinderStrategies::InReplyToStrategy do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to matches a forwarded email' do
|
||||
before do
|
||||
conversation.messages.create!(
|
||||
source_id: 'forward/1@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Forwarded message',
|
||||
content_attributes: { forwarded_message_id: 1 }
|
||||
)
|
||||
mail.in_reply_to = 'forward/1@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil so the reply starts a new conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to has multiple values' do
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
|
||||
@@ -81,6 +81,29 @@ RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a conversation already replied to the same message' do
|
||||
let(:sender) { create(:contact, email: 'sender@example.com', account: account) }
|
||||
let!(:existing_conversation) do
|
||||
create(:conversation, account: account, inbox: email_channel.inbox, contact: sender,
|
||||
additional_attributes: { in_reply_to: '<previous-message@example.com>' })
|
||||
end
|
||||
|
||||
before do
|
||||
create(:contact_inbox, contact: sender, inbox: email_channel.inbox)
|
||||
mail['In-Reply-To'] = '<previous-message@example.com>'
|
||||
end
|
||||
|
||||
it 'reuses the conversation when it belongs to the sender' do
|
||||
expect(described_class.new(mail).find).to eq(existing_conversation)
|
||||
end
|
||||
|
||||
it 'builds a new conversation when it belongs to another contact' do
|
||||
mail.from = 'other@example.com'
|
||||
|
||||
expect(described_class.new(mail).find).to be_new_record
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail is auto reply' do
|
||||
before do
|
||||
mail['X-Autoreply'] = 'yes'
|
||||
|
||||
@@ -56,6 +56,26 @@ RSpec.describe Mailbox::ConversationFinderStrategies::ReferencesStrategy do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references matches a forwarded email' do
|
||||
before do
|
||||
conversation.messages.create!(
|
||||
source_id: 'forward/1@example.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Forwarded message',
|
||||
content_attributes: { forwarded_message_id: 1 }
|
||||
)
|
||||
mail.to = 'test@example.com'
|
||||
mail.references = 'forward/1@example.com'
|
||||
end
|
||||
|
||||
it 'returns nil so the reply starts a new conversation' do
|
||||
strategy = described_class.new(mail)
|
||||
expect(strategy.find).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references has multiple values' do
|
||||
before do
|
||||
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
|
||||
|
||||
Reference in New Issue
Block a user