From 38741f9a15e6215fe8ede4fb072f5a118f93773c Mon Sep 17 00:00:00 2001 From: iamsivin Date: Mon, 28 Apr 2025 16:40:41 +0530 Subject: [PATCH] feat: Email forwarding --- .../messages/forwarded_message_builder.rb | 212 ++++++++++++++++++ app/builders/messages/message_builder.rb | 41 +++- .../components-next/message/MessageList.vue | 15 ++ .../components-next/message/MessageMenu.vue | 56 +++++ .../message/bubbles/Email/EmailMeta.vue | 14 +- .../message/bubbles/Email/Index.vue | 38 +++- .../message/forwardMessage/ForwardMessage.vue | 166 ++++++++++++++ .../components/EmailMessageEditor.vue | 94 ++++++++ .../components/ForwardMessageForm.vue | 187 +++++++++++++++ .../components-next/message/provider.js | 3 +- .../widgets/conversation/MessagesView.vue | 12 + .../i18n/locale/en/conversation.json | 18 ++ app/mailers/conversation_reply_mailer.rb | 1 + app/models/message.rb | 3 +- .../email_reply.html.erb | 14 +- 15 files changed, 853 insertions(+), 21 deletions(-) create mode 100644 app/builders/messages/forwarded_message_builder.rb create mode 100644 app/javascript/dashboard/components-next/message/MessageMenu.vue create mode 100644 app/javascript/dashboard/components-next/message/forwardMessage/ForwardMessage.vue create mode 100644 app/javascript/dashboard/components-next/message/forwardMessage/components/EmailMessageEditor.vue create mode 100644 app/javascript/dashboard/components-next/message/forwardMessage/components/ForwardMessageForm.vue diff --git a/app/builders/messages/forwarded_message_builder.rb b/app/builders/messages/forwarded_message_builder.rb new file mode 100644 index 000000000..945fd77d7 --- /dev/null +++ b/app/builders/messages/forwarded_message_builder.rb @@ -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 "
#{forwarded_header_html}#{forwarded_body_html}
" if html_content.blank? + + "
#{html_content}

#{forwarded_header_html}#{forwarded_body_html}
" + 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 + [ + '
', + '
---------- Forwarded message ---------
', + "From: #{formatted_info[:from].split(' <').first} ", + "<#{extract_email(formatted_info[:from])}>
", + "Date: #{formatted_info[:date]}
", + "Subject: #{formatted_info[:subject]}
", + "To: <#{formatted_info[:to]}>
", + '


' + ].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? + "
#{ERB::Util.html_escape(email_data.dig('text_content', 'full'))}
" + else + "
#{ERB::Util.html_escape(forwarded_message.content.to_s)}
" + 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 text + html = html.gsub(/\*\*?(.*?)\*\*?/) { |m| m.start_with?('**') ? "#{::Regexp.last_match(1)}" : "#{::Regexp.last_match(1)}" } + # Convert _text_ to text + html.gsub(/_(.*?)_/, '\1') + 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 diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index e1087b19f..c0ef6803b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -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 diff --git a/app/javascript/dashboard/components-next/message/MessageList.vue b/app/javascript/dashboard/components-next/message/MessageList.vue index 44b317c56..4f219b079 100644 --- a/app/javascript/dashboard/components-next/message/MessageList.vue +++ b/app/javascript/dashboard/components-next/message/MessageList.vue @@ -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; +}; diff --git a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue index 2e03e97af..ae77f0902 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue @@ -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" > - + > + +
+ + +
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/forwardMessage/ForwardMessage.vue b/app/javascript/dashboard/components-next/message/forwardMessage/ForwardMessage.vue new file mode 100644 index 000000000..576ff2915 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/forwardMessage/ForwardMessage.vue @@ -0,0 +1,166 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/forwardMessage/components/EmailMessageEditor.vue b/app/javascript/dashboard/components-next/message/forwardMessage/components/EmailMessageEditor.vue new file mode 100644 index 000000000..aa92f6055 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/forwardMessage/components/EmailMessageEditor.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/forwardMessage/components/ForwardMessageForm.vue b/app/javascript/dashboard/components-next/message/forwardMessage/components/ForwardMessageForm.vue new file mode 100644 index 000000000..b079d7a55 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/forwardMessage/components/ForwardMessageForm.vue @@ -0,0 +1,187 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/provider.js b/app/javascript/dashboard/components-next/message/provider.js index f4c501845..5e0e3b525 100644 --- a/app/javascript/dashboard/components-next/message/provider.js +++ b/app/javascript/dashboard/components-next/message/provider.js @@ -100,7 +100,8 @@ const MessageControl = Symbol('MessageControl'); * @property {import('vue').ComputedRef} variant - The visual variant of the message * @property {import('vue').ComputedRef} isMyMessage - Does the message belong to the current user * @property {import('vue').ComputedRef} isPrivate - Proxy computed value for private - * @property {import('vue').ComputedRef} 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} 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 - Email content and metadata */ /** diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 8d0c77f1b..c1692f48b 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -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 { +