feat: forward emails from email conversations (#15871)

This commit is contained in:
Sivin Varghese
2026-09-29 16:46:19 -07:00
committed by GitHub
parent 481c6abf90
commit c7d000564e
63 changed files with 2281 additions and 142 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
import { escapeHtml } from 'shared/helpers/HTMLSanitizer';
const mailtoLink = email =>
`&lt;<a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a>&gt;`;
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}`;
};

View File

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

View File

@@ -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 &lt;b&gt;there&lt;/b&gt;<br>Bye &amp; 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> &lt;<a href="mailto:jane@example.com">jane@example.com</a>&gt;<br>',
'Date: Thu, Sep 17, 2026 at 9:39 AM<br>',
'Subject: Order #42<br>',
'To: &lt;<a href="mailto:support@example.com">support@example.com</a>&gt;',
'</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: &lt;<a href="mailto:jane@example.com">jane@example.com</a>&gt;<br>'
);
});
it('lists every recipient', () => {
expect(
build({ recipients: ['a@example.com', 'b@example.com'] })
).toContain(
'To: &lt;<a href="mailto:a@example.com">a@example.com</a>&gt;, &lt;<a href="mailto:b@example.com">b@example.com</a>&gt;'
);
});
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>&lt;script&gt;x&lt;/script&gt;</b>');
expect(html).toContain('mailto:a&quot;b@example.com');
expect(html).toContain('Subject: Re: &lt;img src=x onerror=alert(1)&gt;');
expect(html).toContain('<p>Keep <b>this</b></p>');
expect(html).not.toContain('<script>');
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ module MessageFilterHelpers
end
def conversation_transcriptable?
incoming? || outgoing?
(incoming? || outgoing?) && !forwarded?
end
def email_reply_summarizable?

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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