feat: Email forwarding

This commit is contained in:
iamsivin
2025-04-28 16:40:41 +05:30
parent ef6949e32d
commit 38741f9a15
15 changed files with 853 additions and 21 deletions
@@ -0,0 +1,212 @@
# Handles formatting and preparation of forwarded email messages
class Messages::ForwardedMessageBuilder
def initialize(message_id)
@message_id = message_id
end
def perform
return {} unless @message_id
return basic_attributes unless forwarded_message && email_data.present?
build_forwarded_attributes
end
def formatted_content(original_content = '')
return original_content unless forwarded_message && email_data.present?
original_content = original_content.to_s
original_content + forwarded_header_text + forwarded_body_text
end
def formatted_html_content(original_content = '')
return original_content unless forwarded_message && email_data.present?
html_content = convert_markdown_to_html(original_content)
# Ensure valid HTML structure even with empty content
return "<div dir=\"ltr\">#{forwarded_header_html}#{forwarded_body_html}</div>" if html_content.blank?
"<div dir=\"ltr\">#{html_content}<br><br>#{forwarded_header_html}#{forwarded_body_html}</div>"
end
def forwarded_email_data(original_content = '')
return {} unless forwarded_message && email_data.present?
original_plain = strip_markdown(original_content.to_s)
full_content = formatted_content(original_content)
data = prepare_email_data
# HTML content - ensure quoted section is always included even if empty
data['html_content']['quoted'] = original_plain
data['html_content']['reply'] = full_content
data['html_content']['full'] = formatted_html_content(original_content)
# Text content - ensure quoted section is always included even if empty
data['text_content']['quoted'] = original_content.to_s
data['text_content']['reply'] = full_content
data['text_content']['full'] = full_content
data
end
private
def build_forwarded_attributes
{
content_attributes: {
forwarded_message_id: @message_id,
email: prepare_email_data
# forwarded_info: formatted_info,
}
}
end
def forwarded_header_text
[
"\n\n---------- Forwarded message ---------",
"From: #{formatted_info[:from]}",
"Date: #{formatted_info[:date]}",
"Subject: #{formatted_info[:subject]}",
"To: <#{formatted_info[:to]}>\n\n"
].join("\n")
end
def forwarded_body_text
if email_data.dig('text_content', 'full').present?
email_data.dig('text_content', 'full')
elsif email_data.dig('html_content', 'full').present?
ActionView::Base.full_sanitizer.sanitize(email_data.dig('html_content', 'full'))
else
forwarded_message.content.to_s
end
end
def forwarded_header_html
[
'<div class="gmail_quote gmail_quote_container">',
'<div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>',
"From: <strong class=\"gmail_sendername\" dir=\"auto\">#{formatted_info[:from].split(' <').first}</strong> ",
"<span dir=\"auto\">&lt;<a href=\"mailto:#{extract_email(formatted_info[:from])}\">#{extract_email(formatted_info[:from])}</a>&gt;</span><br>",
"Date: #{formatted_info[:date]}<br>",
"Subject: #{formatted_info[:subject]}<br>",
"To: &lt;<a href=\"mailto:#{formatted_info[:to]}\">#{formatted_info[:to]}</a>&gt;<br>",
'</div><br><br>'
].join
end
def forwarded_body_html
if email_data.dig('html_content', 'full').present?
email_data.dig('html_content', 'full')
elsif email_data.dig('text_content', 'full').present?
"<pre>#{ERB::Util.html_escape(email_data.dig('text_content', 'full'))}</pre>"
else
"<pre>#{ERB::Util.html_escape(forwarded_message.content.to_s)}</pre>"
end
end
def prepare_email_data
data = email_data.dup || {}
data['html_content'] ||= {}
data['text_content'] ||= {}
data
end
def strip_markdown(text)
return '' if text.blank?
text.gsub(/\*\*?(.*?)\*\*?|_(.*?)_/) { |_m| ::Regexp.last_match(1) || ::Regexp.last_match(2) }
end
def convert_markdown_to_html(text)
return '' if text.blank?
# Basic markdown conversion
html = text.to_s
# Convert *text* to <b>text</b>
html = html.gsub(/\*\*?(.*?)\*\*?/) { |m| m.start_with?('**') ? "<b>#{::Regexp.last_match(1)}</b>" : "<i>#{::Regexp.last_match(1)}</i>" }
# Convert _text_ to <i>text</i>
html.gsub(/_(.*?)_/, '<i>\1</i>')
end
def extract_email(from_field)
return '' if from_field.blank?
if from_field =~ /<(.*)>/
::Regexp.last_match(1)
else
from_field
end
end
def forwarded_message
@forwarded_message ||= Message.find_by(id: @message_id)
end
def email_data
@email_data ||= forwarded_message&.content_attributes&.dig('email')
end
def basic_attributes
{ content_attributes: { forwarded_message_id: @message_id, is_forwarded_message: true } }
end
def formatted_info
{
from: format_from_field,
date: format_date_field,
subject: email_data['subject'] || '',
to: email_data['to']&.first || ''
}
end
def format_from_field
from_field = extract_from_field
parse_from_field(from_field)
end
def extract_from_field
email_data['from']&.first.to_s
end
def parse_from_field(from_field)
return '' if from_field.blank?
if from_field =~ /(.*)<(.*)>/
name = ::Regexp.last_match(1).strip
email = ::Regexp.last_match(2).strip
"#{name} <#{email}>"
else
from_field
end
end
def format_date_field
date_str = extract_date_string
format_date_string(date_str)
end
def extract_date_string
email_data['date'] || ''
end
def format_date_string(date_str)
return '' if date_str.blank?
parsed_date = parse_date(date_str)
if parsed_date
parsed_date.strftime('%a, %b %d, %Y at %l:%M %p')
else
date_str
end
end
def parse_date(date_str)
return nil if date_str.blank?
DateTime.parse(date_str)
rescue StandardError
nil
end
end
+35 -6
View File
@@ -9,14 +9,11 @@ class Messages::MessageBuilder
@user = user
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
process_content_attributes
end
def perform
process_forwarded_message if @forwarded_message_id.present?
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
@@ -26,6 +23,21 @@ class Messages::MessageBuilder
private
def process_forwarded_message
builder = Messages::ForwardedMessageBuilder.new(@forwarded_message_id)
@forwarded_attributes = builder.perform
# Update content to include forwarded message
original_content = @params[:content_original] || @params[:content]
@params[:content] = builder.formatted_content(@params[:content])
# Update email data
return unless @forwarded_attributes[:content_attributes] && @conversation.inbox&.inbox_type == 'Email'
# Ensure we have valid email data structure to avoid breaking the rendering
@forwarded_attributes[:content_attributes][:email] = builder.forwarded_email_data(original_content)
end
# Extracts content attributes from the given params.
# - Converts ActionController::Parameters to a regular hash if needed.
# - Attempts to parse a JSON string if content is a string.
@@ -58,6 +70,18 @@ class Messages::MessageBuilder
{}
end
def process_content_attributes
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless @params.instance_of?(ActionController::Parameters)
@forwarded_message_id = content_attributes&.dig(:forwarded_message_id)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
# Store original content before any modifications
@params[:content_original] = @params[:content].dup if @params[:content].present?
end
def process_attachments
return if @attachments.blank?
@@ -151,6 +175,11 @@ class Messages::MessageBuilder
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
}
.merge(external_created_at)
.merge(automation_rule_id)
.merge(campaign_id)
.merge(template_params)
.merge(@forwarded_attributes || {})
end
end
@@ -95,6 +95,16 @@ const getInReplyToMessage = parentMessage => {
return replyMessage ? useCamelCase(replyMessage) : null;
};
/**
* Gets the address of the forwarded message
* @param {Object} message - The message containing the forwarded message reference
* @returns {Array|null} - The email addresses of the forwarded message, or null if not forwarded
*/
const getForwardedMessageAddress = message => {
const { forwardedMessageId, toEmails } = message.contentAttributes || {};
return forwardedMessageId ? toEmails : null;
};
</script>
<template>
@@ -105,6 +115,11 @@ const getInReplyToMessage = parentMessage => {
v-if="firstUnreadId && message.id === firstUnreadId"
name="unreadBadge"
/>
<slot
v-if="getForwardedMessageAddress(message)"
:address="getForwardedMessageAddress(message)"
name="forwardedMessageAddress"
/>
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const emit = defineEmits(['openForward']);
const { t } = useI18n();
const menuItems = computed(() => {
return [
{
label: t('CONVERSATION.MESSAGE_MENU.FORWARD_EMAIL'),
value: 'forward',
},
];
});
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<NextButton
icon="i-lucide-ellipsis-vertical"
xs
slate
faded
:class="{ 'bg-n-alpha-2': isOpen }"
@click="toggle"
/>
</template>
<DropdownBody class="top-0 -right-6 min-w-64 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="item in menuItems"
:key="item.value"
class="!items-start !gap-1 flex-col cursor-pointer"
@click="() => emit('openForward')"
>
<template #label>
<div class="items-start flex gap-1 flex-col">
<span class="text-n-slate-12 text-sm">
{{ item.label }}
</span>
</div>
</template>
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>
@@ -14,7 +14,8 @@ const fromEmail = computed(() => {
});
const toEmail = computed(() => {
return contentAttributes.value?.email?.to ?? [];
const { forwardedMessageId, toEmails, email } = contentAttributes.value;
return forwardedMessageId ? (toEmails ?? []) : (email?.to ?? []);
});
const ccEmail = computed(() => {
@@ -66,10 +67,12 @@ const showMeta = computed(() => {
<template>
<section
v-show="showMeta"
class="space-y-1 rtl:pl-9 ltr:pr-9 text-sm break-words"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
<template v-if="showMeta">
<div
v-if="showMeta"
class="space-y-1 rtl:pl-9 w-full ltr:pr-9 text-sm break-words"
>
<div
v-if="fromEmail[0]"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
@@ -81,7 +84,7 @@ const showMeta = computed(() => {
&lt;{{ fromEmail[0] }}&gt;
</template>
<template v-else>
{{ fromEmail[0] }}
{{ $t('EMAIL_HEADER.FROM') }}: {{ fromEmail[0] }}
</template>
</div>
<div v-if="toEmail.length">
@@ -99,6 +102,7 @@ const showMeta = computed(() => {
{{ $t('EMAIL_HEADER.SUBJECT') }}:
{{ subject }}
</div>
</template>
</div>
<slot />
</section>
</template>
@@ -4,23 +4,26 @@ import { Letter } from 'vue-letter';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
import MessageMenu from 'dashboard/components-next/message/MessageMenu.vue';
import { EmailQuoteExtractor } from './removeReply.js';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import ForwardMessageForm from 'dashboard/components-next/message/forwardMessage/ForwardMessage.vue';
import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js';
import { MESSAGE_TYPES, MESSAGE_STATUS } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } =
const { id, status, content, contentAttributes, attachments, messageType } =
useMessageContext();
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const showForwardMessageModal = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer');
@@ -31,6 +34,12 @@ onMounted(() => {
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isIncoming = computed(() => !isOutgoing.value);
const isForwarded = computed(() => contentAttributes.value?.forwardedMessageId);
const showMessageMenu = computed(
() => ![MESSAGE_STATUS.FAILED, MESSAGE_STATUS.PROGRESS].includes(status.value)
);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
@@ -103,13 +112,30 @@ const handleSeeOriginal = () => {
}"
data-bubble-name="email"
>
<EmailMeta
class="p-3"
<div
class="flex items-start gap-2 justify-end"
:class="{
'border-b border-n-strong': isIncoming,
'border-b border-n-slate-8/20': isOutgoing,
}"
/>
>
<EmailMeta class="p-3 w-full flex justify-end items-start">
<div
v-if="showMessageMenu"
class="flex gap-2 skip-context-menu flex-shrink-0 items-center relative"
>
<MessageMenu @open-forward="showForwardMessageModal = true" />
<ForwardMessageForm
v-if="showForwardMessageModal"
:message="contentAttributes?.email"
:message-id="id"
class="absolute right-3 z-50 skip-context-menu top-10"
@close="showForwardMessageModal = false"
/>
</div>
</EmailMeta>
</div>
<section ref="contentContainer" class="p-3">
<div
:class="{
@@ -130,7 +156,7 @@ const handleSeeOriginal = () => {
</button>
</div>
<FormattedContent
v-if="isOutgoing && content"
v-if="isOutgoing && content && !isForwarded"
class="text-n-slate-12"
:content="messageContent"
/>
@@ -0,0 +1,166 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import { EmailQuoteExtractor } from 'dashboard/components-next/message/bubbles/Email/removeReply.js';
import { debounce } from '@chatwoot/utils';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createNewContact,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ForwardMessageForm from './components/ForwardMessageForm.vue';
const props = defineProps({
forwardType: {
type: String,
default: 'email',
},
message: {
type: Object,
default: () => ({}),
},
messageId: {
type: Number,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const contacts = ref([]);
const selectedContact = ref(null);
const isCreatingContact = ref(false);
const isSearching = ref(false);
const messageSignature = useMapGetter('getMessageSignature');
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const fromEmail = computed(() => props.message?.to?.[0]);
const fullHTML = computed(() => {
return (
props.message?.htmlContent?.full ??
props.message?.textContent?.full?.replace(/\n/g, '<br>')
);
});
const unquotedHTML = computed(() =>
EmailQuoteExtractor.extractQuotes(fullHTML.value)
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
const textToShow = computed(() => {
const text = props.message?.textContent?.full;
return text?.replace(/\n/g, '<br>');
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
isSearching.value = false;
} catch (error) {
useAlert(t('FORWARD_MESSAGE_FORM.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const handleClickOutside = () => {
selectedContact.value = null;
emit('close');
};
const handleForwardMessage = async ({ state }) => {
try {
const messagePayload = {
conversationId: currentChat.value?.id,
message: state.message,
toEmails: selectedContact.value?.email,
private: false,
contentAttributes: {
forwarded_message_id: props.messageId,
},
sender: {
name: currentUser.value?.name,
thumbnail: currentUser.value?.avatar_url,
},
};
await store.dispatch('createPendingMessageAndSend', messagePayload);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
// Close the forward message modal after sending
emit('close');
} catch (error) {
const errorMessage =
error?.response?.data?.error ||
t('FORWARD_MESSAGE_FORM.FORWARD_MESSAGE.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
contact = await createNewContact(value);
isCreatingContact.value = false;
} catch (error) {
isCreatingContact.value = false;
return;
}
} else {
contact = rest;
}
selectedContact.value = contact;
};
</script>
<template>
<div
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
]"
>
<ForwardMessageForm
:forward-type="forwardType"
:contacts="contacts"
:selected-contact="selectedContact"
:is-loading="isSearching"
:is-creating-contact="isCreatingContact"
:from-email="fromEmail"
:message="message"
:message-signature="messageSignature"
:full-html="fullHTML"
:unquoted-html="unquotedHTML"
:text-to-show="textToShow"
:has-quoted-message="hasQuotedMessage"
@search-contacts="onContactSearch"
@update-selected-contact="handleSelectedContact"
@clear-selected-contact="selectedContact = null"
@discard="emit('close')"
@forward-message="handleForwardMessage"
/>
</div>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Letter } from 'vue-letter';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmailMeta from 'dashboard/components-next/message/bubbles/Email/EmailMeta.vue';
// import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
defineProps({
hasQuotedMessage: { type: Boolean, default: false },
fullHtml: { type: String, default: '' },
unquotedHtml: { type: String, default: '' },
textToShow: { type: String, default: '' },
});
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
const showQuotedMessage = ref(false);
</script>
<template>
<div class="flex-1 h-full">
<Editor
v-model="modelValue"
:placeholder="t('FORWARD_MESSAGE_FORM.EMAIL_EDITOR_PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px] [&_.ProseMirror-woot-style]:!min-h-fit"
enable-variables
:show-character-count="false"
/>
<div class="px-4 pb-4 flex flex-col gap-2">
<div class="flex items-center gap-1.5">
<div
class="h-px w-20 bg-n-alpha-2 border-t border-dashed border-n-slate-12"
/>
<span class="font-semibold text-sm text-n-slate-12">
{{ t('FORWARD_MESSAGE_FORM.FORWARDED_MESSAGE') }}
</span>
<div
class="h-px w-20 bg-n-alpha-2 border-t border-dashed border-n-slate-12"
/>
</div>
<EmailMeta />
</div>
<div class="px-4 pb-4">
<Letter
v-if="showQuotedMessage"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:html="fullHtml"
:text="textToShow"
/>
<Letter
v-else
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHtml"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:text="textToShow"
/>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
@click="showQuotedMessage = !showQuotedMessage"
>
<template v-if="showQuotedMessage">
{{ t('FORWARD_MESSAGE_FORM.HIDE_QUOTED_TEXT') }}
</template>
<template v-else>
{{ t('FORWARD_MESSAGE_FORM.SHOW_QUOTED_TEXT') }}
</template>
<Icon
:icon="
showQuotedMessage ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
/>
</button>
</div>
</div>
</template>
@@ -0,0 +1,187 @@
<script setup>
import { ref, computed, reactive } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { required } from '@vuelidate/validators';
import { buildContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
import ActionButtons from 'dashboard/components-next/NewConversation/components/ActionButtons.vue';
import EmailMessageEditor from './EmailMessageEditor.vue';
const props = defineProps({
forwardType: { type: String, default: 'email' }, // eslint-disable-line vue/no-unused-properties
contacts: { type: Array, default: () => [] },
selectedContact: { type: Object, default: null },
isLoading: { type: Boolean, default: false },
isCreatingContact: { type: Boolean, default: false },
fromEmail: { type: String, default: null },
messageSignature: { type: String, default: '' },
fullHtml: { type: String, default: '' },
unquotedHtml: { type: String, default: '' },
textToShow: { type: String, default: '' },
hasQuotedMessage: { type: Boolean, default: false },
});
const emit = defineEmits([
'searchContacts',
'updateSelectedContact',
'clearSelectedContact',
'discard',
'forwardMessage',
]);
const { t } = useI18n();
const state = reactive({
message: '',
attachedFiles: [],
});
const showContactsDropdown = ref(false);
const contactableInboxesList = computed(() => {
return buildContactableInboxesList(props.selectedContact?.contactInboxes);
});
const validationRules = computed(() => ({
selectedContact: { required },
}));
const v$ = useVuelidate(validationRules, {
selectedContact: computed(() => props.selectedContact),
});
const validationStates = computed(() => ({
isContactInvalid:
v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
}));
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email'],
query: value,
});
};
const setSelectedContact = async ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateSelectedContact', { value, action, ...rest });
showContactsDropdown.value = false;
};
const clearSelectedContact = () => {
emit('clearSelectedContact');
// state.attachedFiles = [];
};
const handleDropdownUpdate = (type, value) => {
showContactsDropdown.value = value;
};
const onClickInsertEmoji = emoji => {
state.message += emoji;
};
const handleAddSignature = signature => {
state.message = appendSignature(state.message, signature);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
const clearForm = () => {
Object.assign(state, {
message: '',
attachedFiles: [],
});
v$.value.$reset();
};
const handleSendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
try {
const success = await emit('forwardMessage', { state });
if (success) {
clearForm();
}
} catch (error) {
// Form will not be cleared if conversation creation fails
}
};
</script>
<template>
<div
class="w-[42rem] max-h-[31.25rem] overflow-y-scroll divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<div class="relative flex-1 px-4 py-3 overflow-y-visible bg-n-alpha-3">
<div class="flex items-baseline w-full gap-3 min-h-7">
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('FORWARD_MESSAGE_FORM.FROM') }}
</label>
<div
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ fromEmail }}
</span>
</div>
</div>
</div>
<ContactSelector
class="bg-n-alpha-3"
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="false"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<EmailMessageEditor
v-model="state.message"
class="bg-n-alpha-3"
:has-quoted-message="hasQuotedMessage"
:full-html="fullHtml"
:unquoted-html="unquotedHtml"
:text-to-show="textToShow"
/>
<ActionButtons
class="bg-n-alpha-3 sticky bottom-0 backdrop-blur-[100px]"
:attached-files="state.attachedFiles"
is-email-or-web-widget-inbox
channel-type="Channel::Email"
:is-loading="false"
:disable-send-button="false"
has-selected-inbox
:has-no-inbox="false"
:is-dropdown-active="showContactsDropdown"
:message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"
/>
</div>
</template>
@@ -100,7 +100,8 @@ const MessageControl = Symbol('MessageControl');
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
* @property {import('vue').ComputedRef<boolean>} isMyMessage - Does the message belong to the current user
* @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is different from groupWithNext, this has a bypass for a failed message
* @property {import('vue').ComputedRef<EmailContent>} emailContent - Email content and metadata
*/
/**
@@ -12,6 +12,7 @@ import Message from './Message.vue';
import NextMessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// stores and apis
import { mapGetters } from 'vuex';
@@ -44,6 +45,7 @@ export default {
components: {
Message,
NextMessageList,
Icon,
ReplyBox,
Banner,
ConversationLabelSuggestion,
@@ -563,6 +565,16 @@ export default {
</span>
</li>
</template>
<template #forwardedMessageAddress="{ address }">
<li class="flex items-center gap-1 !mt-4 !mb-2.5 ltr:pl-9 rtl:pr-9 h-5">
<Icon icon="i-lucide-forward" class="text-n-amber-10 size-4" />
<span class="text-n-amber-10 text-xs font-medium leading-[20px]">
{{
$t('CONVERSATION.FORWARDED_TO', { address: address?.join(', ') })
}}
</span>
</li>
</template>
<template #after>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
@@ -62,6 +62,7 @@
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"FORWARDED_TO": "forwarded to {address}",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
@@ -236,6 +237,23 @@
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
},
"MESSAGE_MENU": {
"FORWARD_EMAIL": "Forward email"
}
},
"FORWARD_MESSAGE_FORM": {
"FROM": "From :",
"EMAIL_EDITOR_PLACEHOLDER": "Write your message here...",
"FORWARDED_MESSAGE": "Forwarded message",
"SHOW_QUOTED_TEXT": "Show quoted text",
"HIDE_QUOTED_TEXT": "Hide quoted text",
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldnt complete the search. Please try again."
},
"FORWARD_MESSAGE": {
"ERROR_MESSAGE": "We couldnt able to forward the message. Please try again.",
"SUCCESS_MESSAGE": "The message was forwarded successfully!"
}
},
"EMAIL_TRANSCRIPT": {
+1
View File
@@ -38,6 +38,7 @@ class ConversationReplyMailer < ApplicationMailer
init_conversation_attributes(message.conversation)
@message = message
@has_forwarded_content = message.content_attributes['forwarded_message_id'].present?
reply_mail_object = prepare_mail(true)
message.update(source_id: reply_mail_object.message_id)
end
+2 -1
View File
@@ -101,9 +101,10 @@ class Message < ApplicationRecord
# [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
# [:is_forwarded_message] : Used to indicate that a message is a forwarded message
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id, :is_unsupported], coder: JSON
:translations, :in_reply_to_external_id, :is_unsupported, :is_forwarded_message], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
@@ -1,5 +1,15 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<% if @message.content_attributes&.dig('forwarded_message_id').present? %>
<% if @message.content_attributes&.dig('email').present? %>
<% if @message.content_attributes.dig('email', 'html_content', 'full').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'full').html_safe %>
<% elsif @message.content_attributes.dig('email', 'text_content', 'full').present? %>
<%= simple_format(@message.content_attributes.dig('email', 'text_content', 'full')) %>
<% end %>
<% end %>
<% else %>
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
<% end %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>