From 21366e1c3b306763539cfcf11e164ca0c75dad9f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 30 Sep 2025 17:47:09 +0530 Subject: [PATCH] feat: allow quoted email thread in reply (#12545) This PR adds the ability to include the thread history as a quoted text ## Preview https://github.com/user-attachments/assets/c96a85e5-8ac8-4021-86ca-57509b4eea9f --- app/builders/messages/message_builder.rb | 57 +++ .../message/bubbles/Email/Index.vue | 65 ++-- .../widgets/WootWriter/ReplyBottomPanel.vue | 24 ++ .../conversation/QuotedEmailPreview.vue | 76 ++++ .../widgets/conversation/ReplyBox.vue | 104 +++++- .../composables/spec/useUISettings.spec.js | 25 ++ .../dashboard/composables/useUISettings.js | 18 + app/javascript/dashboard/featureFlags.js | 1 + .../emailQuoteExtractor.js} | 30 ++ .../dashboard/helper/quotedEmailHelper.js | 332 ++++++++++++++++++ .../helper/specs/emailQuoteExtractor.spec.js | 99 ++++++ .../helper/specs/quotedEmailHelper.spec.js | 326 +++++++++++++++++ .../i18n/locale/en/conversation.json | 7 + config/features.yml | 3 + 14 files changed, 1124 insertions(+), 43 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/conversation/QuotedEmailPreview.vue rename app/javascript/dashboard/{components-next/message/bubbles/Email/removeReply.js => helper/emailQuoteExtractor.js} (79%) create mode 100644 app/javascript/dashboard/helper/quotedEmailHelper.js create mode 100644 app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js create mode 100644 app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index e1087b19f..2f95615e9 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -20,6 +20,7 @@ class Messages::MessageBuilder @message = @conversation.messages.build(message_params) process_attachments process_emails + process_email_content @message.save! @message end @@ -92,6 +93,14 @@ class Messages::MessageBuilder @message.content_attributes[:to_emails] = to_emails end + def process_email_content + return unless should_process_email_content? + + @message.content_attributes ||= {} + email_attributes = build_email_attributes + @message.content_attributes[:email] = email_attributes + end + def process_email_string(email_string) return [] if email_string.blank? @@ -153,4 +162,52 @@ class Messages::MessageBuilder source_id: @params[:source_id] }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) end + + def email_inbox? + @conversation.inbox&.inbox_type == 'Email' + end + + def should_process_email_content? + email_inbox? && !@private && @message.content.present? + end + + def build_email_attributes + email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) + normalized_content = normalize_email_body(@message.content) + + email_attributes[:html_content] = build_html_content(normalized_content) + email_attributes[:text_content] = build_text_content(normalized_content) + email_attributes + end + + def build_html_content(normalized_content) + html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {}) + rendered_html = render_email_html(normalized_content) + html_content[:full] = rendered_html + html_content[:reply] = rendered_html + html_content + end + + def build_text_content(normalized_content) + text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {}) + text_content[:full] = normalized_content + text_content[:reply] = normalized_content + text_content + end + + def ensure_indifferent_access(hash) + return {} if hash.blank? + + hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash + end + + def normalize_email_body(content) + content.to_s.gsub("\r\n", "\n") + end + + def render_email_html(content) + return '' if content.blank? + + ChatwootMarkdownRenderer.new(content).render_message.to_s + end end 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 0c7e95c70..1eb3cda10 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue @@ -5,9 +5,8 @@ import { sanitizeTextForRender } from '@chatwoot/utils'; import { allowedCssProperties } from 'lettersanitizer'; import Icon from 'next/icon/Icon.vue'; -import { EmailQuoteExtractor } from './removeReply.js'; +import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.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'; @@ -47,15 +46,6 @@ const originalEmailHtml = computed( originalEmailText.value ); -const messageContent = computed(() => { - // If translations exist and we're showing translations (not original) - if (hasTranslations.value && !renderOriginal.value) { - return translationContent.value; - } - // Otherwise show original content - return content.value; -}); - const textToShow = computed(() => { // If translations exist and we're showing translations (not original) if (hasTranslations.value && !renderOriginal.value) { @@ -136,37 +126,30 @@ const handleSeeOriginal = () => { {{ $t('EMAIL_HEADER.EXPAND') }} - + -