Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub 136c2d8e58 Merge branch 'develop' into feat/email-forwarding 2025-09-22 16:48:42 +05:30
iamsivin 38b1d8fbfa Merge branch 'develop' into feat/email-forwarding 2025-09-17 10:19:06 +05:30
Sivin VargheseandGitHub 2736dc2f90 Merge branch 'develop' into feat/email-forwarding 2025-06-12 15:46:58 +05:30
Sivin VargheseandGitHub d3295b3838 Merge branch 'develop' into feat/email-forwarding 2025-06-04 22:24:17 +05:30
Sivin VargheseandGitHub c464b50854 Merge branch 'develop' into feat/email-forwarding 2025-05-29 15:33:19 +05:30
Sivin VargheseandGitHub 1d174c085d Merge branch 'develop' into feat/email-forwarding 2025-05-29 13:04:35 +05:30
Sivin VargheseandGitHub 690000fb97 Merge branch 'develop' into feat/email-forwarding 2025-05-28 14:47:24 +05:30
Sivin VargheseandGitHub c99d846348 Merge branch 'develop' into feat/email-forwarding 2025-05-28 09:34:00 +05:30
iamsivin 2ff71c21e6 chore: Fix build 2025-05-26 12:50:26 +05:30
Sivin VargheseandGitHub 0862277564 Merge branch 'develop' into feat/email-forwarding 2025-05-23 21:34:24 +05:30
Sivin VargheseandGitHub a9388fe918 Merge branch 'develop' into feat/email-forwarding 2025-05-23 14:02:44 +05:30
iamsivin 2e6998e57a chore: Minor fix 2025-05-23 14:02:13 +05:30
iamsivin d3e3809118 chore: MInor fix 2025-05-23 12:51:40 +05:30
Sivin VargheseandGitHub e973309610 Merge branch 'develop' into feat/email-forwarding 2025-05-23 12:12:31 +05:30
Sivin VargheseandGitHub 81d2c018cd Merge branch 'develop' into feat/email-forwarding 2025-05-21 23:28:22 +05:30
Sivin VargheseandGitHub 7f3db1fe29 Merge branch 'develop' into feat/email-forwarding 2025-05-20 23:45:15 +05:30
Sivin VargheseandGitHub a4d749b9b9 Merge branch 'develop' into feat/email-forwarding 2025-05-18 18:57:32 +05:30
Sivin VargheseandGitHub 4deb4b04af Merge branch 'develop' into feat/email-forwarding 2025-05-16 23:08:16 +05:30
Sivin VargheseandGitHub 5c0f0cf0a8 Merge branch 'develop' into feat/email-forwarding 2025-05-16 22:21:22 +05:30
Sivin VargheseandGitHub 1ce7f1bd64 Merge branch 'develop' into feat/email-forwarding 2025-05-15 15:20:13 +05:30
iamsivin 162929eb7a chore: Minor fix 2025-05-13 17:41:25 +05:30
iamsivin 12ace33254 chore: Clean up 2025-05-13 16:43:45 +05:30
Sivin VargheseandGitHub 32158da454 Merge branch 'develop' into feat/email-forwarding 2025-05-13 15:42:36 +05:30
Sivin VargheseandGitHub 3ad8156ea2 Merge branch 'develop' into feat/email-forwarding 2025-05-12 21:19:04 +05:30
iamsivin aa8cafb9b5 chore: Replace scroll lock composable usage 2025-05-12 20:31:59 +05:30
iamsivin d5267057f6 chore: Minor fix 2025-05-09 18:30:42 +05:30
iamsivin 0a31e6ee63 chore: use CustomTeleport 2025-05-09 17:58:22 +05:30
Sivin VargheseandGitHub 7633602ca5 Merge branch 'develop' into feat/email-forwarding 2025-05-09 17:52:01 +05:30
iamsivin b8ec6d6235 chore: Fix code climate 2025-05-09 17:40:49 +05:30
iamsivin be10e450e3 chore: Minor fix 2025-05-09 17:31:20 +05:30
iamsivin e722bebed8 chore: Minor fix 2025-05-09 17:28:29 +05:30
iamsivin 78ae5ef8fd chore: Lock context menu 2025-05-09 16:53:33 +05:30
iamsivin 0c5379147d chore: Scroll lock composable to reuse 2025-05-09 16:47:53 +05:30
Sivin VargheseandGitHub 9c5af11b84 Merge branch 'develop' into feat/email-forwarding 2025-05-09 08:41:21 +05:30
PranavandGitHub 43c640a76e Merge branch 'develop' into feat/email-forwarding 2025-05-08 19:16:11 -07:00
iamsivin 09e76b21ca chore: Clean up 2025-05-09 00:22:30 +05:30
iamsivin bfec3b066f chore: Fix padding issue 2025-05-09 00:15:25 +05:30
Sivin VargheseandGitHub 9fafd37e3b Merge branch 'develop' into feat/email-forwarding 2025-05-08 22:39:26 +05:30
iamsivin 6def703a60 chore: Minor fix 2025-05-08 17:06:24 +05:30
Sivin VargheseandGitHub 6ca8b3abca Merge branch 'develop' into feat/email-forwarding 2025-05-08 16:52:59 +05:30
iamsivin cffd74fda3 chore: Replace message menu with context menu 2025-05-08 16:52:18 +05:30
Sivin VargheseandGitHub 2856818931 Merge branch 'develop' into feat/email-forwarding 2025-05-08 12:21:12 +05:30
Sivin VargheseandGitHub 3e7df0e76a Merge branch 'develop' into feat/email-forwarding 2025-05-07 21:27:27 +05:30
Sivin VargheseandGitHub d747fbacf8 Merge branch 'develop' into feat/email-forwarding 2025-05-07 14:55:10 +05:30
PranavandGitHub 74c6caa982 Merge branch 'develop' into feat/email-forwarding 2025-05-06 13:44:47 -07:00
Sivin VargheseandGitHub 0203ebb419 Merge branch 'develop' into feat/email-forwarding 2025-05-06 22:43:29 +05:30
Sivin VargheseandGitHub cf09aaadfc Merge branch 'develop' into feat/email-forwarding 2025-05-06 08:33:13 +05:30
iamsivin 421597b1bc chore: Minor fix 2025-05-05 16:25:05 +05:30
Sivin VargheseandGitHub 2db43948c0 Merge branch 'develop' into feat/email-forwarding 2025-05-05 16:09:25 +05:30
iamsivin 7ab4343359 chore: Minor fix 2025-05-02 21:33:20 +05:30
iamsivin 76dbc7540f chore: Fix position issue and lock scroll 2025-05-02 21:21:23 +05:30
iamsivin cc44e7ab88 chore: Preview email attachments 2025-05-02 20:20:14 +05:30
iamsivin c1c488eafa chore: Fix build 2025-05-02 20:13:10 +05:30
Sivin VargheseandGitHub b05312aab1 Merge branch 'develop' into feat/email-forwarding 2025-05-02 14:14:56 +05:30
iamsivin 99d7bd9e7c chore: Code clean up moves to service classes 2025-05-02 14:14:15 +05:30
iamsivin 7aefbba2ca chore: MInor fix 2025-05-02 12:34:33 +05:30
Sivin VargheseandGitHub 7c8f4d26fe Merge branch 'develop' into feat/email-forwarding 2025-05-02 08:07:02 +05:30
iamsivin 7b0aaa2721 chore: Fix code climate 2025-05-01 19:11:25 +05:30
iamsivin 00a9a5cdc2 chore: Minor fix 2025-05-01 19:02:25 +05:30
iamsivin ccd24c40e7 chore: Minor fix 2025-05-01 18:59:35 +05:30
iamsivin 754d0ff515 chore: Minor fix 2025-05-01 18:52:25 +05:30
iamsivin f8d3a2a989 chore: Improve formatting with commonmarker and fix spec 2025-05-01 18:40:25 +05:30
iamsivin d1ac7211ec chore: Forward outgoing email message 2025-05-01 17:12:05 +05:30
Sivin VargheseandGitHub f4ca2529ba Merge branch 'develop' into feat/email-forwarding 2025-05-01 13:13:29 +05:30
iamsivin 1094a3924e chore: Forward plain email UI 2025-04-30 22:57:25 +05:30
iamsivin 8326eae4fe chore: Forward with attachments 2025-04-30 22:29:33 +05:30
iamsivin e96f296414 chore: Send with attachment 2025-04-30 11:05:59 +05:30
Sivin VargheseandGitHub abce44c737 Merge branch 'develop' into feat/email-forwarding 2025-04-30 08:35:28 +05:30
iamsivin ab9d10488c chore: Fix spec 2025-04-29 22:03:27 +05:30
Sivin VargheseandGitHub a4a749e0d5 Merge branch 'develop' into feat/email-forwarding 2025-04-29 18:14:30 +05:30
iamsivin f20ef2bfc7 chore: Adds spec 2025-04-29 15:41:42 +05:30
iamsivin 65b38bb4e4 chore: Adds multipart support on email rendering 2025-04-29 14:35:03 +05:30
Sivin VargheseandGitHub 9ab40adc24 Merge branch 'develop' into feat/email-forwarding 2025-04-29 09:23:11 +05:30
iamsivin 77feacb544 chore: Fix date 2025-04-28 22:19:39 +05:30
iamsivin 32664bb840 chore: Minor fix 2025-04-28 22:15:07 +05:30
iamsivin c710c55cf1 chore: Minor fix 2025-04-28 16:44:37 +05:30
iamsivin 38741f9a15 feat: Email forwarding 2025-04-28 16:40:41 +05:30
30 changed files with 2237 additions and 21 deletions
+59 -6
View File
@@ -9,16 +9,14 @@ 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_forwarded_attachments if @forwarded_message_id.present?
process_emails
@message.save!
@message
@@ -26,6 +24,44 @@ class Messages::MessageBuilder
private
def process_forwarded_message
builder = Messages::ForwardedMessageBuilderService.new(@forwarded_message_id, { to_emails: @params[:to_emails] })
@forwarded_attributes = builder.perform
@forwarded_message_attachments = builder.forwarded_attachments
# 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
# Process attachments from the forwarded message
def process_forwarded_attachments
return if @forwarded_message_attachments.blank?
@forwarded_message_attachments.each do |source_attachment|
# Create a new attachment for the current message
attachment = @message.attachments.build(
account_id: @message.account_id,
file_type: source_attachment.file_type
)
# Attach the file by directly copying it from the source attachment
next unless source_attachment.file.attached?
attachment.file.attach(
io: StringIO.new(source_attachment.file.download),
filename: source_attachment.file.filename.to_s,
content_type: source_attachment.file.content_type
)
end
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 +94,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 +199,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
@@ -148,6 +148,7 @@ const keyboardEvents = {
emit('sendMessage');
}
},
allowOnFocusedInput: true,
},
'$mod+Enter': {
action: () => {
@@ -159,6 +160,7 @@ const keyboardEvents = {
emit('sendMessage');
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, computed, ref, toRefs } from 'vue';
import { onMounted, computed, ref, toRefs, useTemplateRef } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
@@ -136,6 +136,7 @@ const emit = defineEmits(['retry']);
const contextMenuPosition = ref({});
const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
const emailBubbleRef = useTemplateRef('emailBubbleRef');
const { t } = useI18n();
const route = useRoute();
@@ -364,6 +365,16 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
// Forward email is enabled only when the message is not in progress and is not private and is an email inbox
forwardEmail:
props.isEmailInbox &&
!props.private &&
props.status !== MESSAGE_STATUS.PROGRESS &&
![
CONTENT_TYPES.FORM,
CONTENT_TYPES.INPUT_CSAT,
CONTENT_TYPES.CARDS,
].includes(props.contentType),
};
});
@@ -384,6 +395,9 @@ const shouldRenderMessage = computed(() => {
});
function openContextMenu(e) {
// Close forward modal, when opening context menu
emailBubbleRef.value?.closeForwardModal();
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
@@ -459,6 +473,11 @@ const setupHighlightTimer = () => {
}, HIGHLIGHT_TIMER);
};
const openForwardModal = ({ x, y }) => {
// Open forward modal, with the event from context menu
emailBubbleRef.value.openForwardModal({ x, y });
};
onMounted(setupHighlightTimer);
provideMessageContext({
@@ -519,7 +538,12 @@ provideMessageContext({
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" />
<component
:is="componentToRender"
:ref="
componentToRender === EmailBubble ? 'emailBubbleRef' : undefined
"
/>
</div>
<MessageError
v-if="contentAttributes.externalError"
@@ -540,6 +564,7 @@ provideMessageContext({
@open="openContextMenu"
@close="closeContextMenu"
@reply-to="handleReplyTo"
@forward-email="openForwardModal"
/>
</div>
</div>
@@ -97,6 +97,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>
@@ -107,6 +117,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"
@@ -14,8 +14,8 @@ const fromEmail = computed(() => {
});
const toEmail = computed(() => {
const { toEmails, email } = contentAttributes.value;
return email?.to ?? toEmails ?? [];
const { forwardedMessageId, toEmails, email } = contentAttributes.value;
return forwardedMessageId ? (toEmails ?? []) : (email?.to ?? []);
});
const ccEmail = computed(() => {
@@ -82,7 +82,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">
@@ -1,8 +1,9 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { computed, useTemplateRef, ref, onMounted, reactive } from 'vue';
import { Letter } from 'vue-letter';
import { sanitizeTextForRender } from '@chatwoot/utils';
import { allowedCssProperties } from 'lettersanitizer';
import { useToggle } from '@vueuse/core';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
@@ -11,20 +12,29 @@ 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 ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import { useMessageContext } from '../../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } =
const { id, content, contentAttributes, attachments, messageType } =
useMessageContext();
const { inbox } = useInbox();
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer');
// Forward form - managed locally but can be triggered by parent
const [showForwardMessageModal, toggleForwardModal] = useToggle();
const forwardFormPosition = reactive({ top: 0, right: 0 });
onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400;
});
@@ -32,6 +42,8 @@ onMounted(() => {
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isIncoming = computed(() => !isOutgoing.value);
const isForwarded = computed(() => contentAttributes.value?.forwardedMessageId);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
@@ -93,6 +105,23 @@ const translationKeySuffix = computed(() => {
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
const closeForwardModal = () => {
toggleForwardModal(false);
forwardFormPosition.x = 0;
forwardFormPosition.y = 0;
};
const openForwardModal = ({ x, y }) => {
forwardFormPosition.x = x;
forwardFormPosition.y = y;
toggleForwardModal(true);
};
defineExpose({
openForwardModal,
closeForwardModal,
});
</script>
<template>
@@ -111,6 +140,7 @@ const handleSeeOriginal = () => {
'border-b border-n-slate-8/20': isOutgoing,
}"
/>
<section ref="contentContainer" class="p-3">
<div
:class="{
@@ -137,7 +167,7 @@ const handleSeeOriginal = () => {
</button>
</div>
<FormattedContent
v-if="isOutgoing && content"
v-if="isOutgoing && content && !isForwarded"
class="text-n-slate-12"
:content="messageContent"
/>
@@ -200,6 +230,22 @@ const handleSeeOriginal = () => {
>
<AttachmentChips :attachments="attachments" class="gap-1" />
</section>
<ContextMenu
v-if="showForwardMessageModal"
:x="forwardFormPosition.x"
:y="forwardFormPosition.y"
@close="closeForwardModal"
>
<ForwardMessageForm
:message="contentAttributes?.email"
:content="content"
:inbox="inbox"
:attachments="attachments"
:message-id="id"
@close="closeForwardModal"
/>
</ContextMenu>
</BaseBubble>
</template>
@@ -0,0 +1,185 @@
<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: () => ({}),
},
content: {
type: String,
default: '',
},
inbox: {
type: Object,
default: () => ({}),
},
messageId: {
type: Number,
default: null,
},
attachments: {
type: Array,
default: () => [],
},
});
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 globalConfig = useMapGetter('globalConfig/get');
const fromEmail = computed(() => props.inbox?.email);
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,
},
files: globalConfig.value?.directUploadsEnabled
? state.attachedFiles.map(file => file.blobSignedId)
: state.attachedFiles.map(file => file.resource.file),
};
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"
:attachments="attachments"
:message-signature="messageSignature"
:content="content"
:is-plain-email="!message || !Object.keys(message).length"
: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,97 @@
<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({
content: { type: String, default: '' },
isPlainEmail: { type: Boolean, default: false },
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">
<span class="text-sm text-n-slate-12">
{{ t('FORWARD_MESSAGE_FORM.FORWARDED_MESSAGE') }}
</span>
</div>
<EmailMeta />
</div>
<div class="px-4 pb-4">
<FormattedContent
v-if="isPlainEmail"
class="text-n-slate-12"
:content="content"
/>
<template v-else>
<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"
/>
</template>
<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,213 @@
<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 AttachmentPreviews from 'dashboard/components-next/NewConversation/components/AttachmentPreviews.vue';
import EmailMessageEditor from './EmailMessageEditor.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.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: '' },
content: { type: String, default: '' },
isPlainEmail: { type: Boolean, default: false },
fullHtml: { type: String, default: '' },
unquotedHtml: { type: String, default: '' },
textToShow: { type: String, default: '' },
hasQuotedMessage: { type: Boolean, default: false },
attachments: { type: Array, default: () => [] },
});
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] divide-y divide-n-strong 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 overflow-hidden"
>
<div
class="relative flex-1 rounded-t-xl 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"
/>
<div class="overflow-y-scroll">
<EmailMessageEditor
v-model="state.message"
class="bg-n-alpha-3"
:content="content"
:is-plain-email="isPlainEmail"
:has-quoted-message="hasQuotedMessage"
:full-html="fullHtml"
:unquoted-html="unquotedHtml"
:text-to-show="textToShow"
/>
<section
v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 pt-2 !border-t-0 space-y-2 bg-n-alpha-3"
>
<AttachmentChips
:attachments="attachments"
class="gap-1 !justify-start"
/>
</section>
</div>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
class="bg-n-alpha-3"
@update:attachments="state.attachedFiles = $event"
/>
<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,7 @@ const MessageControl = Symbol('MessageControl');
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - 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
*/
/**
@@ -24,7 +24,7 @@ const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
return elementToLock.value.$el;
});
const isLocked = useScrollLock(scrollLockElement);
@@ -11,6 +11,7 @@ import ReplyBox from './ReplyBox.vue';
import MessageList 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';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
@@ -44,6 +45,7 @@ export default {
Banner,
ConversationLabelSuggestion,
Spinner,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -495,6 +497,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"
@@ -67,6 +67,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"
@@ -258,6 +259,7 @@
"TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"FORWARD_EMAIL": "Forward email",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
@@ -270,6 +272,20 @@
"COPILOT": "Copilot"
}
},
"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": {
"TITLE": "Send conversation transcript",
"DESC": "Send a copy of the conversation transcript to the specified email address",
@@ -44,7 +44,7 @@ export default {
default: false,
},
},
emits: ['open', 'close', 'replyTo'],
emits: ['open', 'close', 'replyTo', 'forwardEmail'],
setup() {
const { getPlainText } = useMessageFormatter();
@@ -116,6 +116,13 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
openForwardModal() {
this.handleClose();
this.$emit('forwardEmail', {
x: this.contextMenuPosition.x,
y: this.contextMenuPosition.y,
});
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
this.$store.dispatch('translateMessage', {
@@ -240,6 +247,15 @@ export default {
variant="icon"
@click.stop="showCannedResponseModal"
/>
<MenuItem
v-if="enabledOptions['forwardEmail']"
:option="{
icon: 'forward',
label: $t('CONVERSATION.CONTEXT_MENU.FORWARD_EMAIL'),
}"
variant="icon"
@click.stop="openForwardModal"
/>
<hr v-if="enabledOptions['delete']" />
<MenuItem
v-if="enabledOptions['delete']"
@@ -285,5 +285,6 @@
"M9.60364 9.20645C9.60364 8.67008 10.0385 8.23523 10.5749 8.23523C11.1113 8.23523 11.5461 8.67008 11.5461 9.20645V11.4511C11.5461 11.9875 11.1113 12.4223 10.5749 12.4223C10.0385 12.4223 9.60364 11.9875 9.60364 11.4511V9.20645Z",
"M17.1442 5.57049C13.5275 5.06019 10.5793 5.04007 6.88135 5.56825C5.9466 5.70176 5.32812 5.79197 4.85654 5.92976C4.41928 6.05757 4.17061 6.20994 3.96492 6.43984C3.539 6.91583 3.48286 7.45419 3.4248 9.33184C3.36775 11.1772 3.48076 12.831 3.69481 14.6918C3.80887 15.6834 3.88736 16.3526 4.01268 16.8613C4.13155 17.3439 4.27532 17.6034 4.47513 17.802C4.67654 18.0023 4.93467 18.1435 5.40841 18.2581C5.90952 18.3793 6.56702 18.4526 7.5442 18.5592C10.7045 18.904 13.0702 18.9022 16.2423 18.561C17.2313 18.4546 17.8995 18.3813 18.4081 18.2609C18.8913 18.1465 19.1511 18.0063 19.3497 17.8118C19.5442 17.6213 19.6928 17.3587 19.8217 16.852C19.9561 16.3234 20.0476 15.624 20.18 14.5966C20.4162 12.7633 20.5863 11.1533 20.5929 9.3896C20.5999 7.50391 20.5613 6.96737 20.1306 6.46971C19.9226 6.22932 19.6696 6.0713 19.2224 5.93968C18.7395 5.79754 18.1042 5.70594 17.1442 5.57049ZM6.65555 3.98715C10.5078 3.43695 13.6072 3.45849 17.3674 3.98902L17.4224 3.99678C18.3127 4.12235 19.0648 4.22844 19.6733 4.40753C20.33 4.60078 20.8792 4.89417 21.3382 5.4245C22.2041 6.42482 22.1984 7.6117 22.1909 9.18858C22.1905 9.25686 22.1902 9.32584 22.19 9.3956C22.183 11.2604 22.0026 12.949 21.764 14.8006L21.7577 14.8496C21.6332 15.8159 21.5307 16.6121 21.3695 17.2458C21.2 17.9121 20.9467 18.4833 20.4672 18.9529C19.9919 19.4183 19.4302 19.6602 18.776 19.8151C18.1582 19.9613 17.3895 20.044 16.4629 20.1436L16.4131 20.149C13.1283 20.5023 10.6472 20.5043 7.37097 20.1469L7.32043 20.1414C6.40679 20.0417 5.64604 19.9587 5.03292 19.8104C4.38112 19.6527 3.82317 19.406 3.34911 18.9347C2.87346 18.4618 2.62363 17.8999 2.46191 17.2433C2.30938 16.6241 2.22071 15.8531 2.11393 14.9246L2.10815 14.8743C1.88863 12.9659 1.76823 11.23 1.82845 9.28246C1.83063 9.2118 1.83272 9.14191 1.83479 9.07281C1.8816 7.50776 1.91671 6.33374 2.7747 5.37486C3.22992 4.86612 3.76798 4.58399 4.40853 4.39678C5.00257 4.22316 5.73505 4.11858 6.60207 3.99479C6.61981 3.99225 6.63764 3.9897 6.65555 3.98715Z"
],
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0"
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0",
"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"
}
+1
View File
@@ -39,6 +39,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
@@ -0,0 +1,114 @@
# frozen_string_literal: true
class Messages::ForwardedMessageBuilderService
attr_reader :message_id, :params
def initialize(message_id, params = {})
@message_id = message_id
@params = params || {}
end
def perform
return {} unless message_id
return basic_attributes if forwarded_message.blank?
build_forwarded_attributes
end
def formatted_content(original_content = '')
return original_content.to_s if forwarded_message.blank?
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
formatted_info = data_handler.formatted_info
content_builder = Messages::ForwardedMessageContentBuilderService.new(
formatted_info,
forwarded_message,
email_data
)
content_builder.formatted_content(original_content)
end
def forwarded_email_data(original_content = '')
return {} if forwarded_message.blank?
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
data = data_handler.prepare_email_data
formatted_info = data_handler.formatted_info
content_builder = Messages::ForwardedMessageContentBuilderService.new(
formatted_info,
forwarded_message,
email_data
)
process_email_content(data, content_builder, original_content)
end
def forwarded_attachments
forwarded_message.attachments if forwarded_message&.attachments.present?
end
private
def process_email_content(data, content_builder, original_content)
full_content = content_builder.formatted_content(original_content)
process_text_content(data, original_content, full_content)
process_html_content(data, content_builder, original_content, full_content)
add_attachments(data)
data
end
def process_text_content(data, original_content, full_content)
# Convert markdown in original_content to plain text
original_plain = Messages::ForwardedMessageFormatterService.markdown_to_plain_text(original_content.to_s)
# Convert full_content to plain text if it contains markdown
full_plain = Messages::ForwardedMessageFormatterService.markdown_to_plain_text(full_content)
data['text_content'].merge!(
'quoted' => original_plain,
'reply' => full_plain,
'full' => full_plain
)
end
def process_html_content(data, content_builder, original_content, full_content)
stripped = Messages::ForwardedMessageFormatterService.strip_markdown(original_content.to_s)
html_full = content_builder.formatted_html_content(original_content)
data['html_content'].merge!(
'quoted' => stripped,
'reply' => full_content,
'full' => html_full
)
end
def add_attachments(data)
return if forwarded_message.attachments.blank?
data['attachments'] = forwarded_message.attachments.map(&:serializable_hash)
end
def build_forwarded_attributes
{ content_attributes: { forwarded_message_id: message_id, email: prepare_email_data } }
end
def prepare_email_data
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
data_handler.prepare_email_data
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 } }
end
end
@@ -0,0 +1,60 @@
# frozen_string_literal: true
class Messages::ForwardedMessageContentBuilderService
attr_reader :formatted_info, :forwarded_message, :email_data
def initialize(formatted_info, forwarded_message, email_data)
@formatted_info = formatted_info
@forwarded_message = forwarded_message
@email_data = email_data
end
def forwarded_header_text
return '' unless formatted_info.values.any?
build_header_lines.compact.join("\n")
end
def forwarded_body_text
return forwarded_message.content.to_s if email_data.blank?
text = email_data.dig('text_content', 'full')
html = email_data.dig('html_content', 'full')
if text.present?
text
elsif html.present?
ActionView::Base.full_sanitizer.sanitize(html)
else
forwarded_message.content.to_s
end
end
def formatted_content(original_content = '')
return original_content.to_s if forwarded_message.blank?
original_content.to_s + forwarded_header_text + forwarded_body_text
end
def formatted_html_content(original_content = '')
return original_content if forwarded_message.blank?
# Always use markdown conversion since it handles plain text correctly
converted_content = Messages::ForwardedMessageFormatterService.convert_markdown_to_html(original_content)
html_builder = Messages::ForwardedMessageHtmlBuilderService.new(formatted_info, forwarded_message, email_data)
html_builder.html_wrapper(converted_content)
end
private
def build_header_lines
[
"\n\n---------- Forwarded message ---------",
("From: #{formatted_info[:from]}" if formatted_info[:from].present?),
("Date: #{formatted_info[:date]}" if formatted_info[:date].present?),
("Subject: #{formatted_info[:subject]}" if formatted_info[:subject].present?),
("To: <#{formatted_info[:to]}>" if formatted_info[:to].present?),
"\n"
]
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
class Messages::ForwardedMessageDataHandlerService
attr_reader :forwarded_message, :email_data, :params
def initialize(forwarded_message, email_data, params = {})
@forwarded_message = forwarded_message
@email_data = email_data
@params = params || {}
end
def prepare_email_data
initialize_email_data
end
def formatted_info
{
from: inbox_email.to_s,
date: Messages::ForwardedMessageFormatterService.format_date_string(email_date),
subject: subject.to_s,
to: recipient_email.to_s
}
end
private
def initialize_email_data
data = base_email_data
add_content_fields(data)
add_header_fields(data)
data
end
def base_email_data
email_data.present? ? email_data.dup || {} : {}
end
def add_content_fields(data)
data['html_content'] ||= {}
data['text_content'] ||= {}
end
def add_header_fields(data)
data['from'] = [email_from_inbox] # Always overwrite with inbox email
data['to'] = Array(params[:to_emails]) if params && params[:to_emails].present?
data['subject'] ||= subject
data['date'] ||= Time.zone.now.to_s
end
def inbox_email
email_from_data || email_from_inbox
end
def email_from_data
return nil unless email_data_has_from?
from_field = email_data['from'].first.to_s
Messages::ForwardedMessageFormatterService.parse_from_field(from_field)
end
def email_data_has_from?
email_data.present? &&
email_data['from'].present? &&
email_data['from'].first.present?
end
def email_from_inbox
inbox = forwarded_message&.conversation&.inbox
return nil if inbox.blank? || inbox.channel_type != 'Channel::Email'
email = inbox.channel&.email
return nil if email.blank?
email
end
def recipient_email
return email_data&.dig('to', 0) if email_data&.dig('to', 0).present?
forwarded_message&.content_attributes&.dig('to_emails', 0).presence
end
def subject
email_data&.dig('subject').presence ||
forwarded_message&.conversation&.additional_attributes&.dig('subject').presence ||
'No Subject'
end
def email_date
email_data&.dig('date').presence || Time.zone.now.to_s
end
end
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class Messages::ForwardedMessageFormatterService
def self.parse_from_field(from_field)
return '' if from_field.blank?
from_field =~ /(.*)<(.*)>/ ? Regexp.last_match(2).strip : from_field
end
def self.format_plain_text_to_html(text)
ERB::Util.html_escape(text.to_s).gsub("\n", '<br>')
end
def self.extract_email(from_field)
return '' if from_field.blank?
from_field =~ /<(.*)>/ ? Regexp.last_match(1) : from_field
end
def self.format_date_string(date_str)
return '' if date_str.blank?
begin
parsed_date = DateTime.now
parsed_date.strftime('%a, %b %-d, %Y at %-l:%M %p')
rescue StandardError
date_str
end
end
def self.strip_markdown(text)
return '' if text.blank?
# Convert markdown to HTML using CommonMarker
html = CommonMarker.render_html(text, :DEFAULT)
# Strip HTML tags to get plain text
ActionView::Base.full_sanitizer.sanitize(html)
end
def self.convert_markdown_to_html(text)
return '' if text.blank?
# Use CommonMarker with GitHub Flavored Markdown options
options = [:GITHUB_PRE_LANG, :UNSAFE]
extensions = [:table, :strikethrough, :autolink]
CommonMarker.render_html(text, options, extensions)
end
# Convert text to plain text, stripping any markdown formatting
def self.markdown_to_plain_text(text)
return '' if text.blank?
# Simply strip any markdown by converting to HTML and sanitizing
strip_markdown(text)
end
end
@@ -0,0 +1,68 @@
# frozen_string_literal: true
class Messages::ForwardedMessageHtmlBuilderService
attr_reader :formatted_info, :forwarded_message, :email_data
def initialize(formatted_info, forwarded_message, email_data)
@formatted_info = formatted_info
@forwarded_message = forwarded_message
@email_data = email_data
end
def html_wrapper(content)
base = '<div dir="ltr">'
if content.blank?
"#{base}#{forwarded_header_html}#{forwarded_body_html}</div>"
else
"#{base}#{content}<br><br>#{forwarded_header_html}#{forwarded_body_html}</div>"
end
end
def forwarded_header_html
from_value = formatted_info[:from].to_s
email = Messages::ForwardedMessageFormatterService.extract_email(from_value)
[
'<div class="gmail_quote gmail_quote_container">',
'<div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>',
build_email_html(email),
build_field_html('Date', formatted_info[:date]),
build_field_html('Subject', formatted_info[:subject]),
build_to_html(formatted_info[:to]),
'</div><br><br>'
].compact.join
end
def forwarded_body_html
# Return HTML content directly if available
return email_data.dig('html_content', 'full') if email_data&.dig('html_content', 'full').present?
# Otherwise format content to HTML
content = if email_data&.dig('text_content', 'full').present?
email_data.dig('text_content', 'full')
else
forwarded_message.content.to_s
end
# Always use markdown conversion since it handles plain text correctly
Messages::ForwardedMessageFormatterService.convert_markdown_to_html(content)
end
private
def build_email_html(email)
return nil if email.blank?
"From: &lt;<a href=\"mailto:#{email}\">#{email}</a>&gt;<br>"
end
def build_field_html(label, value)
value.present? ? "#{label}: #{value}<br>" : nil
end
def build_to_html(to_email)
return nil if to_email.blank?
"To: &lt;<a href=\"mailto:#{to_email}\">#{to_email}</a>&gt;<br>"
end
end
@@ -1,6 +1,23 @@
<% if @message.content %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% if @message.content_attributes&.dig('forwarded_message_id').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? %>
<div>
<%= simple_format(@message.content_attributes.dig('email', 'text_content', 'full')) %>
</div>
<% elsif @message.content %>
<div>
<%= ChatwootMarkdownRenderer.new(@message.content).render_message.html_safe %>
</div>
<% end %>
<% else %>
<% if @message.content %>
<div>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
</div>
<% end %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
<% @large_attachments.each do |attachment| %>
@@ -0,0 +1,18 @@
<% if @message.content_attributes&.dig('forwarded_message_id').present? %>
<% if @message.content_attributes&.dig('email', 'text_content', 'full').present? %>
<%= @message.content_attributes&.dig('email', 'text_content', 'full') %>
<% elsif @message.content %>
<%= @message.content %>
<% end %>
<% else %>
<% if @message.content %>
<%= @message.content %>
<% end %>
<% end %>
<% if @large_attachments.present? %>
Attachments:
<% @large_attachments.each do |attachment| %>
<%= attachment.file.filename.to_s %>: <%= attachment.file_url %>
<% end %>
<% end %>
@@ -181,4 +181,247 @@ describe Messages::MessageBuilder do
end
end
end
describe '#perform with forwarded messages' do
def create_email_channel_with_inbox
channel = create(:channel_email, account: account)
inbox = channel.inbox
{
channel: channel,
inbox: inbox
}
end
def create_email_conversations(inbox)
{
source: create(:conversation, inbox: inbox, account: account),
target: create(:conversation, inbox: inbox, account: account)
}
end
def standard_email_data
{
'from' => ['sender@example.com'],
'to' => ['recipient@example.com'],
'subject' => 'Test Subject',
'date' => '2025-04-29T14:29:07+05:30',
'html_content' => {
'full' => '<div>HTML content</div>',
'quoted' => 'HTML content',
'reply' => 'HTML content'
},
'text_content' => {
'full' => 'Text content',
'quoted' => 'Text content',
'reply' => 'Text content'
}
}
end
def setup_email_environment
channel_data = create_email_channel_with_inbox
conversations = create_email_conversations(channel_data[:inbox])
{
email_inbox: channel_data[:inbox],
email_conversation: conversations[:source],
target_conversation: conversations[:target],
standard_email_data: standard_email_data
}
end
context 'with different message types' do
let(:env) { setup_email_environment }
it 'preserves email data when forwarding' do
# Create the original message to be forwarded
forwarded_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: env[:standard_email_data] })
# Setup params to forward the message
forward_params = ActionController::Parameters.new({
content: 'Forwarded message:',
content_attributes: { forwarded_message_id: forwarded_message.id }
})
message = described_class.new(user, env[:target_conversation], forward_params).perform
expect(message.content_attributes[:forwarded_message_id]).to eq(forwarded_message.id)
expect(message.content_attributes[:email]).to be_present
expect(message.content).to include('Forwarded message:')
expect(message.content).to include('---------- Forwarded message ---------')
html_content = message.content_attributes[:email]['html_content']['full']
text_content = message.content_attributes[:email]['text_content']['full']
expect(html_content).to include('<div>HTML content</div>')
expect(text_content).to include('Text content')
end
it 'handles markdown content in forwarded messages' do
# Create the original message to be forwarded
forwarded_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: env[:standard_email_data] })
markdown_content = '**Bold text** and _italic text_'
forward_params = ActionController::Parameters.new({
content: markdown_content,
content_attributes: { forwarded_message_id: forwarded_message.id }
})
message = described_class.new(user, env[:email_conversation], forward_params).perform
html_content = message.content_attributes[:email]['html_content']['full']
text_quoted = message.content_attributes[:email]['text_content']['quoted']
full_text = message.content_attributes[:email]['text_content']['full']
expect(html_content).to include('<strong>Bold text</strong>')
expect(html_content).to include('<em>italic text</em>')
expect(text_quoted.strip).to eq('Bold text and italic text')
expect(full_text).to include('Bold text and italic text')
expect(full_text).to include('---------- Forwarded message ---------')
end
it 'returns empty email data when forwarding a message with no email data' do
regular_message = create(:message, conversation: conversation, account: account)
forward_params = ActionController::Parameters.new({
content: 'Forwarding a regular message:',
content_attributes: { forwarded_message_id: regular_message.id }
})
# Create the forwarded message
message = described_class.new(user, env[:email_conversation], forward_params).perform
# Updated expectation - we now expect email data to be present but won't check specific content
expect(message.content_attributes[:forwarded_message_id]).to eq(regular_message.id)
expect(message.content_attributes[:email]).to be_present
end
it 'preserves multipart content in forwarded messages' do
# Create a multipart email message
multipart_data = env[:standard_email_data].merge(
'html_content' => { 'full' => '<div>HTML <b>formatted</b> content</div>' },
'text_content' => { 'full' => 'Plain text content' }
)
multipart_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: multipart_data })
forward_params = ActionController::Parameters.new({
content: 'Forwarding multipart email:',
content_attributes: { forwarded_message_id: multipart_message.id }
})
message = described_class.new(user, env[:email_conversation], forward_params).perform
# Verify multipart content is preserved
expect(message.content_attributes[:email]['html_content']['full']).to include('<div>HTML <b>formatted</b> content</div>')
expect(message.content_attributes[:email]['text_content']['full']).to include('Plain text content')
end
end
context 'with attachments' do
let(:env) { setup_email_environment }
def create_base_message(conversation)
create(:message,
conversation: conversation,
account: account,
content_attributes: {
email: standard_email_data.merge(
'html_content' => { 'full' => '<div>Message with attachments</div>' },
'text_content' => { 'full' => 'Message with attachments' }
)
})
end
def add_text_attachment(message)
attachment = message.attachments.new(account_id: account.id, file_type: 'file')
attachment.file.attach(
io: StringIO.new('test file content'),
filename: 'test.txt',
content_type: 'text/plain'
)
attachment.save!
end
def add_image_attachment(message)
attachment = message.attachments.new(account_id: account.id, file_type: 'image')
attachment.file.attach(
io: StringIO.new('fake image content'),
filename: 'test.jpg',
content_type: 'image/jpeg'
)
attachment.save!
end
def create_message_with_attachments(conversation)
message = create_base_message(conversation)
add_text_attachment(message)
add_image_attachment(message)
message
end
it 'copies attachments from the forwarded message' do
message_with_attachments = create_message_with_attachments(env[:email_conversation])
forward_params = ActionController::Parameters.new({
content: 'Forwarding message with attachments:',
content_attributes: { forwarded_message_id: message_with_attachments.id }
})
message = described_class.new(user, env[:email_conversation], forward_params).perform
# Verify attachments are copied
expect(message.attachments.count).to eq(2)
expect(message.attachments.map(&:file_type)).to include('file', 'image')
expect(message.attachments.first.file).to be_attached
# Verify attachment data in content_attributes
expect(message.content_attributes[:email]['attachments']).to be_present
expect(message.content_attributes[:email]['attachments'].length).to eq(2)
end
end
context 'with nested forwarding' do
let(:env) { setup_email_environment }
it 'maintains proper forwarding chain data' do
# Create original message
parent_message = create(:message,
conversation: env[:email_conversation],
account: account,
content_attributes: { email: env[:standard_email_data] })
# Create first level forward
first_level_params = ActionController::Parameters.new({
content: 'First forwarded message:',
content_attributes: { forwarded_message_id: parent_message.id }
})
first_forward = described_class.new(user, env[:email_conversation], first_level_params).perform
# Create second level forward
second_level_params = ActionController::Parameters.new({
content: 'Forwarding a forwarded message:',
content_attributes: { forwarded_message_id: first_forward.id }
})
message = described_class.new(user, env[:email_conversation], second_level_params).perform
expect(message.content_attributes[:forwarded_message_id]).to be_present
expect(message.content).to include('Forwarding a forwarded message:')
expect(message.content).to include('---------- Forwarded message ---------')
expect(message.content).to include('First forwarded message:')
end
end
end
end
@@ -240,7 +240,15 @@ RSpec.describe ConversationReplyMailer do
end
it 'renders the body' do
expect(mail.decoded).to include message.content
body_content = if mail.multipart?
# Check either HTML part or text part for the content
mail.html_part&.body&.decoded || mail.text_part&.body&.decoded
else
# Fallback to single-part handling
mail.body.decoded
end
expect(body_content).to include message.content
end
it 'updates the source_id' do
@@ -0,0 +1,218 @@
require 'rails_helper'
RSpec.describe Messages::ForwardedMessageBuilderService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
let(:user) { create(:user, account: account) }
let(:base_email_content) do
{
'from' => ['sender@example.com'],
'to' => ['recipient@example.com'],
'subject' => 'Test Subject',
'date' => '2025-04-29T14:29:07+05:30',
'html_content' => {
'full' => '<div>HTML content</div>',
'quoted' => 'HTML content',
'reply' => 'HTML content'
},
'text_content' => {
'full' => 'Text content',
'quoted' => 'Text content',
'reply' => 'Text content'
}
}
end
# Create a message with email data
let(:forwarded_message) do
create(:message, conversation: conversation, account: account,
content_attributes: { email: base_email_content })
end
# Create a message with multipart email data
let(:multipart_message) do
create(:message, conversation: conversation, account: account,
content_attributes: {
email: base_email_content.merge(
'html_content' => { 'full' => '<div>HTML <b>formatted</b> content</div>' },
'text_content' => { 'full' => 'Plain text content' }
)
})
end
# Create a message with email data and attachments
let(:message_with_attachments) do
message = create(:message, conversation: conversation, account: account,
content_attributes: {
email: base_email_content.merge(
'html_content' => { 'full' => '<div>Message with attachments</div>' },
'text_content' => { 'full' => 'Message with attachments' }
)
})
# Add attachments to the message
attachment1 = message.attachments.new(account_id: account.id, file_type: 'file')
attachment1.file.attach(io: StringIO.new('test file content'), filename: 'test.txt', content_type: 'text/plain')
attachment1.save!
attachment2 = message.attachments.new(account_id: account.id, file_type: 'image')
attachment2.file.attach(io: StringIO.new('fake image content'), filename: 'test.jpg', content_type: 'image/jpeg')
attachment2.save!
message
end
# Create a message with no email data
let(:regular_message) { create(:message, conversation: conversation, account: account) }
describe '#perform' do
context 'when message_id is nil' do
it 'returns an empty hash' do
builder = described_class.new(nil)
expect(builder.perform).to eq({})
end
end
context 'when forwarded message is not found' do
it 'returns basic attributes' do
builder = described_class.new(999)
expect(builder.perform).to eq(content_attributes: { forwarded_message_id: 999 })
end
end
context 'when forwarded message has no email data' do
it 'returns basic attributes with empty email structure' do
builder = described_class.new(regular_message.id)
result = builder.perform
expect(result[:content_attributes][:forwarded_message_id]).to eq(regular_message.id)
expect(result[:content_attributes][:email]).to be_present
expect(result[:content_attributes][:email]).to have_key('date')
expect(result[:content_attributes][:email]).to have_key('subject')
expect(result[:content_attributes][:email]).to have_key('from')
end
end
context 'when forwarded message has email data' do
it 'returns forwarded attributes' do
builder = described_class.new(forwarded_message.id)
result = builder.perform
expect(result).to be_a(Hash)
expect(result[:content_attributes]).to be_present
expect(result[:content_attributes][:forwarded_message_id]).to eq(forwarded_message.id)
expect(result[:content_attributes][:email]).to be_present
end
end
end
describe '#formatted_content' do
context 'when forwarded message is blank' do
it 'returns original content' do
builder = described_class.new(999) # Non-existent message ID
expect(builder.formatted_content('Original')).to eq('Original')
end
end
context 'when forwarded message has no email data' do
it 'returns formatted content with basic information' do
builder = described_class.new(regular_message.id)
result = builder.formatted_content('Original')
expect(result).to include('Original')
expect(result).to include('---------- Forwarded message ---------')
end
end
context 'when forwarded message has email data' do
it 'returns formatted content with header and body' do
builder = described_class.new(forwarded_message.id)
result = builder.formatted_content('Original')
expect(result).to include('Original')
expect(result).to include('---------- Forwarded message ---------')
expect(result).to include('Subject: Test Subject')
expect(result).to include('Text content') # Should include the text content
end
end
end
describe '#forwarded_email_data' do
context 'when forwarded message is blank' do
it 'returns empty hash' do
builder = described_class.new(999)
expect(builder.forwarded_email_data('Original')).to eq({})
end
end
context 'when forwarded message has email data' do
it 'returns complete email data structure' do
builder = described_class.new(forwarded_message.id)
result = builder.forwarded_email_data('**Original**')
expect(result).to be_a(Hash)
expect(result['html_content']).to be_present
expect(result['text_content']).to be_present
expect(result['html_content']['full']).to include('Original')
expect(result['text_content']['full']).to include('---------- Forwarded message ---------')
end
end
context 'when handling multipart emails' do
it 'preserves both HTML and text parts' do
builder = described_class.new(multipart_message.id)
result = builder.forwarded_email_data('New message')
expect(result['html_content']['full']).to include('<div>HTML <b>formatted</b> content</div>')
expect(result['text_content']['full']).to include('Plain text content')
end
end
context 'when forwarding a message with attachments' do
it 'includes attachments in the forwarded email data' do
builder = described_class.new(message_with_attachments.id)
result = builder.forwarded_email_data('Original content')
expect(result['attachments']).to be_present
expect(result['attachments'].length).to eq(2)
expect(result['attachments'].pluck('file_type')).to match_array(%w[file image])
end
end
context 'when forwarding a message without attachments' do
it 'does not include attachments in the forwarded email data' do
builder = described_class.new(forwarded_message.id)
result = builder.forwarded_email_data('Original content')
expect(result['attachments']).to be_nil
end
end
end
describe '#forwarded_attachments' do
context 'with attachments' do
it 'provides access to the forwarded message attachments' do
builder = described_class.new(message_with_attachments.id)
attachments = builder.forwarded_attachments
expect(attachments).to be_present
expect(attachments.length).to eq(2)
expect(attachments.map(&:file_type)).to match_array(%w[file image])
expect(attachments.first.file).to be_attached
end
end
context 'without attachments' do
it 'returns nil when getting forwarded attachments' do
builder = described_class.new(forwarded_message.id)
attachments = builder.forwarded_attachments
expect(attachments).to be_nil
end
end
end
end
@@ -0,0 +1,144 @@
require 'rails_helper'
RSpec.describe Messages::ForwardedMessageContentBuilderService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
let(:formatted_info) do
{
from: 'sender@example.com',
date: 'Wed, Apr 29, 2025 at 2:29 PM',
subject: 'Test Subject',
to: 'recipient@example.com'
}
end
let(:base_email_content) do
{
'html_content' => {
'full' => '<div>HTML content</div>'
},
'text_content' => {
'full' => 'Text content'
}
}
end
let(:forwarded_message) do
create(:message, conversation: conversation, account: account,
content_attributes: { email: base_email_content })
end
let(:html_only_message) do
create(:message, conversation: conversation, account: account,
content_attributes: {
email: {
'html_content' => { 'full' => '<div>HTML only content</div>' },
'text_content' => {}
}
})
end
let(:content_builder) { described_class.new(formatted_info, forwarded_message, base_email_content) }
let(:html_only_builder) { described_class.new(formatted_info, html_only_message, html_only_message.content_attributes[:email]) }
let(:empty_builder) { described_class.new(formatted_info, forwarded_message, {}) }
describe '#forwarded_header_text' do
it 'returns a formatted header with all information' do
header = content_builder.forwarded_header_text
expect(header).to include('---------- Forwarded message ---------')
expect(header).to include('From: sender@example.com')
expect(header).to include('Date: Wed, Apr 29, 2025 at 2:29 PM')
expect(header).to include('Subject: Test Subject')
expect(header).to include('To: <recipient@example.com>')
end
it 'returns a formatted header without missing fields' do
empty_info = { from: '', date: '', subject: '', to: '' }
builder = described_class.new(empty_info, forwarded_message, base_email_content)
expect(builder.forwarded_header_text).to include('---------- Forwarded message ---------')
expect(builder.forwarded_header_text).not_to include('From: ')
expect(builder.forwarded_header_text).not_to include('Date: ')
expect(builder.forwarded_header_text).not_to include('Subject: ')
expect(builder.forwarded_header_text).not_to include('To: ')
end
it 'handles partial information' do
partial_info = { from: 'sender@example.com', subject: 'Test Subject' }
builder = described_class.new(partial_info, forwarded_message, base_email_content)
header = builder.forwarded_header_text
expect(header).to include('From: sender@example.com')
expect(header).to include('Subject: Test Subject')
expect(header).not_to include('Date:')
expect(header).not_to include('To:')
end
end
describe '#forwarded_body_text' do
it 'returns text content when available' do
expect(content_builder.forwarded_body_text).to eq('Text content')
end
it 'returns sanitized HTML content when only HTML is available' do
expect(html_only_builder.forwarded_body_text).to include('HTML only content')
end
it 'returns message content when no email data is available' do
expect(empty_builder.forwarded_body_text).to eq(forwarded_message.content.to_s)
end
end
describe '#formatted_content' do
it 'returns original content concatenated with header and body' do
result = content_builder.formatted_content('Original message')
expect(result).to start_with('Original message')
expect(result).to include('---------- Forwarded message ---------')
expect(result).to include('Text content')
end
it 'returns just the original content when forwarded message is blank' do
builder = described_class.new(formatted_info, nil, base_email_content)
expect(builder.formatted_content('Original message')).to eq('Original message')
end
it 'handles empty original content' do
result = content_builder.formatted_content('')
expect(result).to include('---------- Forwarded message ---------')
expect(result).to include('Text content')
end
end
describe '#formatted_html_content' do
it 'converts markdown in original content to HTML' do
html_builder = instance_double(Messages::ForwardedMessageHtmlBuilderService)
allow(Messages::ForwardedMessageHtmlBuilderService).to receive(:new)
.with(formatted_info, forwarded_message, base_email_content)
.and_return(html_builder)
allow(Messages::ForwardedMessageFormatterService).to receive(:convert_markdown_to_html)
.with('**Original** message')
.and_return('<p>Converted HTML</p>')
allow(html_builder).to receive(:html_wrapper)
.with('<p>Converted HTML</p>')
.and_return('<div>Wrapped content</div>')
result = content_builder.formatted_html_content('**Original** message')
expect(Messages::ForwardedMessageFormatterService).to have_received(:convert_markdown_to_html)
.with('**Original** message')
expect(result).to eq('<div>Wrapped content</div>')
end
it 'returns original content when forwarded message is blank' do
builder = described_class.new(formatted_info, nil, base_email_content)
expect(builder.formatted_html_content('Original message')).to eq('Original message')
end
end
end
@@ -0,0 +1,180 @@
require 'rails_helper'
RSpec.describe Messages::ForwardedMessageDataHandlerService do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, account: account) }
let(:email_inbox) { create(:inbox, channel: email_channel, account: account) }
let(:conversation) { create(:conversation, inbox: email_inbox, account: account) }
let(:regular_inbox) { create(:inbox, account: account) }
let(:regular_conversation) { create(:conversation, inbox: regular_inbox, account: account) }
let(:base_email_content) do
{
'from' => ['John Doe <sender@example.com>'],
'to' => ['recipient@example.com'],
'subject' => 'Test Subject',
'date' => '2025-04-29T14:29:07+05:30'
}
end
# Message with email data and from an email inbox
let(:email_message) do
create(:message, conversation: conversation, account: account,
content_attributes: { email: base_email_content })
end
# Message with email data but from a regular inbox
let(:regular_message) do
create(:message, conversation: regular_conversation, account: account,
content_attributes: { email: base_email_content })
end
# Message with no email data
let(:message_no_email) { create(:message, conversation: conversation, account: account) }
describe '#prepare_email_data' do
it 'returns empty hash structure when email_data is nil' do
handler = described_class.new(message_no_email, nil)
result = handler.prepare_email_data
expect(result).to be_a(Hash)
expect(result['html_content']).to eq({})
expect(result['text_content']).to eq({})
expect(result).to have_key('date')
expect(result).to have_key('subject')
end
it 'preserves existing email data and adds required fields' do
handler = described_class.new(email_message, base_email_content)
result = handler.prepare_email_data
expect(result).to be_a(Hash)
expect(result['html_content']).to eq({})
expect(result['text_content']).to eq({})
expect(result['from']).to be_present
expect(result['to']).to eq(['recipient@example.com'])
expect(result['subject']).to eq('Test Subject')
expect(result['date']).to eq('2025-04-29T14:29:07+05:30')
end
it 'adds to_emails from params if provided' do
handler = described_class.new(email_message, base_email_content, { to_emails: 'new@example.com' })
result = handler.prepare_email_data
expect(result['to']).to eq(['new@example.com'])
end
it 'overwrites from field with inbox email' do
handler = described_class.new(email_message, base_email_content)
allow(handler).to receive(:email_from_inbox).and_return('inbox@example.com')
result = handler.prepare_email_data
expect(result['from']).to eq(['inbox@example.com'])
end
end
describe '#formatted_info' do
it 'returns hash with properly formatted fields' do
allow(Messages::ForwardedMessageFormatterService).to receive(:format_date_string).and_return('Formatted Date')
handler = described_class.new(email_message, base_email_content)
result = handler.formatted_info
expect(result[:from]).to be_present
expect(result[:date]).to eq('Formatted Date')
expect(result[:subject]).to eq('Test Subject')
expect(result[:to]).to eq('recipient@example.com')
expect(Messages::ForwardedMessageFormatterService).to have_received(:format_date_string).with('2025-04-29T14:29:07+05:30')
end
it 'works with missing email data' do
# Create instance first, then stub its methods
message_without_email = build(:message)
handler = described_class.new(message_without_email, nil)
# Stub the methods on the specific instance
allow(handler).to receive(:email_from_data).and_return(nil)
allow(handler).to receive(:email_from_inbox).and_return(nil)
# For email_date, we should expect it to use Time.zone.now.to_s when email_data is nil
# So we should mock the format_date_string to return a predictable value
allow(Messages::ForwardedMessageFormatterService).to receive(:format_date_string).with(kind_of(String)).and_return('Fri, May 2, 2025 at 12:33 PM') # rubocop:disable Layout/LineLength
result = handler.formatted_info
expect(result[:from]).to eq('')
expect(result[:date]).to eq('Fri, May 2, 2025 at 12:33 PM')
expect(result[:subject]).to eq('No Subject')
expect(result[:to]).to eq('')
end
end
context 'when private methods are called' do
describe '#inbox_email' do
it 'returns email from data when available' do
handler = described_class.new(email_message, base_email_content)
allow(handler).to receive(:email_from_data).and_return('from_data@example.com')
allow(handler).to receive(:email_from_inbox).and_return('from_inbox@example.com')
result = handler.send(:inbox_email)
expect(result).to eq('from_data@example.com')
end
it 'returns email from inbox when email data is not available' do
handler = described_class.new(email_message, {})
allow(handler).to receive(:email_from_data).and_return(nil)
allow(handler).to receive(:email_from_inbox).and_return('from_inbox@example.com')
result = handler.send(:inbox_email)
expect(result).to eq('from_inbox@example.com')
end
end
describe '#email_from_data' do
it 'parses email address from from field' do
handler = described_class.new(email_message, base_email_content)
allow(Messages::ForwardedMessageFormatterService).to receive(:parse_from_field).and_return('parsed@example.com')
result = handler.send(:email_from_data)
expect(Messages::ForwardedMessageFormatterService).to have_received(:parse_from_field).with('John Doe <sender@example.com>')
expect(result).to eq('parsed@example.com')
end
it 'returns nil when from field is not available' do
handler = described_class.new(email_message, {})
result = handler.send(:email_from_data)
expect(result).to be_nil
end
end
describe '#subject' do
it 'returns subject from email data when available' do
handler = described_class.new(email_message, base_email_content)
result = handler.send(:subject)
expect(result).to eq('Test Subject')
end
it 'returns subject from conversation when available' do
allow(conversation).to receive(:additional_attributes).and_return({ 'subject' => 'Conversation Subject' })
handler = described_class.new(email_message, {})
result = handler.send(:subject)
expect(result).to eq('Conversation Subject')
end
it 'returns "No Subject" when no subject is available' do
handler = described_class.new(message_no_email, nil)
result = handler.send(:subject)
expect(result).to eq('No Subject')
end
end
end
end
@@ -0,0 +1,158 @@
require 'rails_helper'
RSpec.describe Messages::ForwardedMessageFormatterService do
describe '.convert_markdown_to_html' do
it 'converts bold markdown to HTML' do
formatted = described_class.convert_markdown_to_html('**Bold Text**')
# Updated expectation to match CommonMarker output format
expect(formatted).to include('<strong>Bold Text</strong>')
end
it 'converts italic markdown to HTML' do
formatted = described_class.convert_markdown_to_html('*Italic Text*')
expect(formatted).to include('<em>Italic Text</em>')
end
it 'converts underscore italic markdown to HTML' do
formatted = described_class.convert_markdown_to_html('_Italic Text_')
expect(formatted).to include('<em>Italic Text</em>')
end
it 'handles multiple markdown elements' do
formatted = described_class.convert_markdown_to_html('**Bold** and _italic_')
expect(formatted).to include('<strong>Bold</strong>')
expect(formatted).to include('<em>italic</em>')
end
it 'handles empty text' do
expect(described_class.convert_markdown_to_html('')).to eq('')
end
it 'handles nil text' do
expect(described_class.convert_markdown_to_html(nil)).to eq('')
end
end
describe '.strip_markdown' do
it 'strips bold markdown' do
stripped = described_class.strip_markdown('**Bold Text**')
expect(stripped.strip).to eq('Bold Text')
end
it 'strips italic markdown' do
stripped = described_class.strip_markdown('*Italic Text*')
expect(stripped.strip).to eq('Italic Text')
end
it 'strips underscore italic markdown' do
stripped = described_class.strip_markdown('_Italic Text_')
expect(stripped.strip).to eq('Italic Text')
end
it 'handles multiple markdown elements' do
stripped = described_class.strip_markdown('**Bold** and _italic_')
# Updated expectation to handle trailing newline
expect(stripped.strip).to eq('Bold and italic')
end
it 'handles empty text' do
expect(described_class.strip_markdown('')).to eq('')
end
it 'handles nil text' do
expect(described_class.strip_markdown(nil)).to eq('')
end
end
describe '.extract_email' do
it 'extracts email from format "Name <email@example.com>"' do
extracted = described_class.extract_email('John Doe <john@example.com>')
expect(extracted).to eq('john@example.com')
end
it 'returns plain email as-is' do
extracted = described_class.extract_email('john@example.com')
expect(extracted).to eq('john@example.com')
end
it 'handles empty string' do
expect(described_class.extract_email('')).to eq('')
end
it 'handles nil' do
expect(described_class.extract_email(nil)).to eq('')
end
end
describe '.parse_from_field' do
it 'extracts email from format "Name <email@example.com>"' do
parsed = described_class.parse_from_field('John Doe <john@example.com>')
expect(parsed).to eq('john@example.com')
end
it 'returns plain email as-is' do
parsed = described_class.parse_from_field('john@example.com')
expect(parsed).to eq('john@example.com')
end
it 'handles empty string' do
expect(described_class.parse_from_field('')).to eq('')
end
it 'handles nil' do
expect(described_class.parse_from_field(nil)).to eq('')
end
end
describe '.format_date_string' do
it 'formats the date in a readable format' do
# NOTE: We don't test the exact formatted output since it uses DateTime.now
# which would be different for each test run
result = described_class.format_date_string('2025-04-29T14:29:07+05:30')
expect(result).to match(/\w{3}, \w{3} \d+, \d{4} at \d+:\d+ [AP]M/)
end
it 'handles empty string' do
expect(described_class.format_date_string('')).to eq('')
end
it 'handles nil' do
expect(described_class.format_date_string(nil)).to eq('')
end
end
describe '.format_plain_text_to_html' do
it 'converts newlines to <br>' do
result = described_class.format_plain_text_to_html("Line 1\nLine 2")
expect(result).to eq('Line 1<br>Line 2')
end
it 'escapes HTML special characters' do
result = described_class.format_plain_text_to_html('<script>alert("XSS")</script>')
expect(result).to eq('&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;')
end
it 'handles empty string' do
expect(described_class.format_plain_text_to_html('')).to eq('')
end
it 'handles nil' do
expect(described_class.format_plain_text_to_html(nil)).to eq('')
end
end
describe '.markdown_to_plain_text' do
it 'converts markdown text to plain text' do
plain_text = described_class.markdown_to_plain_text('**Bold** and _italic_ text')
expect(plain_text.strip).to eq('Bold and italic text')
end
it 'handles empty string' do
expect(described_class.markdown_to_plain_text('')).to eq('')
end
it 'handles nil' do
expect(described_class.markdown_to_plain_text(nil)).to eq('')
end
end
end
@@ -0,0 +1,156 @@
require 'rails_helper'
RSpec.describe Messages::ForwardedMessageHtmlBuilderService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
let(:formatted_info) do
{
from: 'sender@example.com',
date: 'Wed, Apr 29, 2025 at 2:29 PM',
subject: 'Test Subject',
to: 'recipient@example.com'
}
end
let(:base_email_content) do
{
'html_content' => {
'full' => '<div>HTML content</div>'
},
'text_content' => {
'full' => 'Text content'
}
}
end
let(:forwarded_message) do
create(:message, conversation: conversation, account: account,
content_attributes: { email: base_email_content })
end
let(:html_builder) { described_class.new(formatted_info, forwarded_message, base_email_content) }
let(:empty_builder) { described_class.new({}, forwarded_message, {}) }
describe '#html_wrapper' do
it 'wraps content with proper HTML structure' do
result = html_builder.html_wrapper('<p>Test content</p>')
expect(result).to start_with('<div dir="ltr">')
expect(result).to include('<p>Test content</p>')
expect(result).to include('<br><br>')
expect(result).to include('---------- Forwarded message ---------')
expect(result).to end_with('</div>')
end
it 'includes header and body even when content is blank' do
result = html_builder.html_wrapper('')
expect(result).to start_with('<div dir="ltr">')
expect(result).to include('---------- Forwarded message ---------')
expect(result).to include('<br><br>')
expect(result).to end_with('</div>')
end
end
describe '#forwarded_header_html' do
it 'formats header with all fields' do
allow(Messages::ForwardedMessageFormatterService).to receive(:extract_email).and_return('sender@example.com')
result = html_builder.forwarded_header_html
expect(result).to include('gmail_quote_container')
expect(result).to include('---------- Forwarded message ---------')
expect(result).to include('From: &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;')
expect(result).to include('Date: Wed, Apr 29, 2025 at 2:29 PM')
expect(result).to include('Subject: Test Subject')
expect(result).to include('To: &lt;<a href="mailto:recipient@example.com">recipient@example.com</a>&gt;')
expect(Messages::ForwardedMessageFormatterService).to have_received(:extract_email).with('sender@example.com')
end
it 'omits empty fields' do
empty_info = { from: '', subject: 'Test Subject' }
builder = described_class.new(empty_info, forwarded_message, base_email_content)
result = builder.forwarded_header_html
expect(result).to include('---------- Forwarded message ---------')
expect(result).to include('Subject: Test Subject')
expect(result).not_to include('From:')
expect(result).not_to include('Date:')
expect(result).not_to include('To:')
end
end
describe '#forwarded_body_html' do
it 'returns HTML content when available' do
result = html_builder.forwarded_body_html
expect(result).to eq('<div>HTML content</div>')
end
it 'converts text to HTML when HTML is not available' do
text_only_content = { 'text_content' => { 'full' => 'Text only content' } }
builder = described_class.new(formatted_info, forwarded_message, text_only_content)
allow(Messages::ForwardedMessageFormatterService).to receive(:convert_markdown_to_html).and_return('<p>Converted HTML</p>')
result = builder.forwarded_body_html
expect(Messages::ForwardedMessageFormatterService).to have_received(:convert_markdown_to_html).with('Text only content')
expect(result).to eq('<p>Converted HTML</p>')
end
it 'falls back to message content when no email data is available' do
allow(forwarded_message).to receive(:content).and_return('Message content')
empty_builder.forwarded_body_html
expect(Messages::ForwardedMessageFormatterService).to receive(:convert_markdown_to_html).with('Message content')
empty_builder.forwarded_body_html
end
end
describe 'private methods' do
describe '#build_email_html' do
it 'formats email with HTML markup' do
result = html_builder.send(:build_email_html, 'test@example.com')
expect(result).to eq('From: &lt;<a href="mailto:test@example.com">test@example.com</a>&gt;<br>')
end
it 'returns nil for blank email' do
expect(html_builder.send(:build_email_html, '')).to be_nil
expect(html_builder.send(:build_email_html, nil)).to be_nil
end
end
describe '#build_field_html' do
it 'formats field with label and value' do
result = html_builder.send(:build_field_html, 'Test', 'Value')
expect(result).to eq('Test: Value<br>')
end
it 'returns nil for blank value' do
expect(html_builder.send(:build_field_html, 'Test', '')).to be_nil
expect(html_builder.send(:build_field_html, 'Test', nil)).to be_nil
end
end
describe '#build_to_html' do
it 'formats to field with HTML markup' do
result = html_builder.send(:build_to_html, 'recipient@example.com')
expect(result).to eq('To: &lt;<a href="mailto:recipient@example.com">recipient@example.com</a>&gt;<br>')
end
it 'returns nil for blank email' do
expect(html_builder.send(:build_to_html, '')).to be_nil
expect(html_builder.send(:build_to_html, nil)).to be_nil
end
end
end
end