From 368d7c4608e9de49a75f0e9d512da203ebd52178 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 15 Oct 2025 00:52:23 -0700 Subject: [PATCH 01/22] feat: Add support for HTML emails in outgoing messages (#12662) This PR adds sending custom HTML content in outgoing email messages through Chatwoot's Email channels, while maintaining backward compatibility with existing markdown rendering. ### API Usage **Endpoint:** `POST /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages` ```json { "content": "Fallback text content", "email_html_content": "

Welcome!

This is custom HTML

" } ``` --------- Co-authored-by: Muhsin --- app/builders/messages/message_builder.rb | 21 +++- .../email_reply.html.erb | 14 ++- .../builders/messages/message_builder_spec.rb | 57 +++++++++ .../mailers/conversation_reply_mailer_spec.rb | 112 ++++++++++++++++++ 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 86bcee54e..12a74ed9c 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -178,7 +178,13 @@ class Messages::MessageBuilder 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) + # Use custom HTML content if provided, otherwise generate from message content + email_attributes[:html_content] = if custom_email_content_provided? + build_custom_html_content + else + build_html_content(normalized_content) + end + email_attributes[:text_content] = build_text_content(normalized_content) email_attributes end @@ -213,4 +219,17 @@ class Messages::MessageBuilder ChatwootMarkdownRenderer.new(content).render_message.to_s end + + def custom_email_content_provided? + @params[:email_html_content].present? + end + + def build_custom_html_content + html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {}) + + html_content[:full] = @params[:email_html_content] + html_content[:reply] = @params[:email_html_content] + + html_content + end end diff --git a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb index feb5dff96..f5f827e4c 100644 --- a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb +++ b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb @@ -1,9 +1,11 @@ -<% if @message.content %> - <%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %> +<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %> +<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %> +<% elsif @message.content %> +<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %> <% end %> <% if @large_attachments.present? %> -

Attachments:

- <% @large_attachments.each do |attachment| %> -

<%= attachment.file.filename.to_s %>

- <% end %> +

Attachments:

+<% @large_attachments.each do |attachment| %> +

<%= attachment.file.filename.to_s %>

+<% end %> <% end %> diff --git a/spec/builders/messages/message_builder_spec.rb b/spec/builders/messages/message_builder_spec.rb index 891f8eb02..2eb4dbf90 100644 --- a/spec/builders/messages/message_builder_spec.rb +++ b/spec/builders/messages/message_builder_spec.rb @@ -179,6 +179,63 @@ describe Messages::MessageBuilder do expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com'] expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com'] end + + context 'when custom email content is provided' do + before do + account.enable_features('quoted_email_reply') + end + + it 'creates message with custom HTML email content' do + params = ActionController::Parameters.new({ + content: 'Regular message content', + email_html_content: '

Custom HTML content

' + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq '

Custom HTML content

' + expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq '

Custom HTML content

' + expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content' + expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content' + end + + it 'does not process custom email content when quoted_email_reply feature is disabled' do + account.disable_features('quoted_email_reply') + params = ActionController::Parameters.new({ + content: 'Regular message content', + email_html_content: '

Custom HTML content

' + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content')).to be_nil + expect(message.content_attributes.dig('email', 'text_content')).to be_nil + end + + it 'does not process custom email content for private messages' do + params = ActionController::Parameters.new({ + content: 'Regular message content', + email_html_content: '

Custom HTML content

', + private: true + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content')).to be_nil + expect(message.content_attributes.dig('email', 'text_content')).to be_nil + end + + it 'falls back to default behavior when no custom email content is provided' do + params = ActionController::Parameters.new({ + content: 'Regular **markdown** content' + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('markdown') + expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content' + end + end end end end diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index ecd97333e..3f6395566 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -335,6 +335,118 @@ RSpec.describe ConversationReplyMailer do expect(mail.body.encoded).not_to match(%r{]*>avatar\.png}) end end + + context 'with custom email content' do + it 'uses custom HTML content when available and creates multipart email' do + message_with_custom_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular message content', + content_attributes: { + email: { + html_content: { + reply: '

Custom HTML content for email

' + }, + text_content: { + reply: 'Custom text content for email' + } + } + }) + + mail = described_class.email_reply(message_with_custom_content).deliver_now + + # Check HTML part contains custom HTML content + html_part = mail.html_part || mail + expect(html_part.body.encoded).to include('

Custom HTML content for email

') + expect(html_part.body.encoded).not_to include('Regular message content') + + # Check text part contains custom text content + text_part = mail.text_part + if text_part + expect(text_part.body.encoded).to include('Custom text content for email') + expect(text_part.body.encoded).not_to include('Regular message content') + end + end + + it 'falls back to markdown rendering when custom HTML content is not available' do + message_without_custom_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular **markdown** content') + + mail = described_class.email_reply(message_without_custom_content).deliver_now + + html_part = mail.html_part || mail + expect(html_part.body.encoded).to include('markdown') + expect(html_part.body.encoded).to include('Regular') + end + + it 'handles empty custom HTML content gracefully' do + message_with_empty_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular **markdown** content', + content_attributes: { + email: { + html_content: { + reply: '' + } + } + }) + + mail = described_class.email_reply(message_with_empty_content).deliver_now + + html_part = mail.html_part || mail + expect(html_part.body.encoded).to include('markdown') + expect(html_part.body.encoded).to include('Regular') + end + + it 'handles nil custom HTML content gracefully' do + message_with_nil_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular **markdown** content', + content_attributes: { + email: { + html_content: { + reply: nil + } + } + }) + + mail = described_class.email_reply(message_with_nil_content).deliver_now + + expect(mail.body.encoded).to include('markdown') + expect(mail.body.encoded).to include('Regular') + end + + it 'uses custom text content in text part when only text is provided' do + message_with_text_only = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular message content', + content_attributes: { + email: { + text_content: { + reply: 'Custom text content only' + } + } + }) + + mail = described_class.email_reply(message_with_text_only).deliver_now + + text_part = mail.text_part + if text_part + expect(text_part.body.encoded).to include('Custom text content only') + expect(text_part.body.encoded).not_to include('Regular message content') + end + end + end end context 'when smtp enabled for email channel' do From 40577e6a507bbab2594637f76a26d10ad881015a Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 15 Oct 2025 22:05:13 -0700 Subject: [PATCH 02/22] Bump version to 4.7.0 --- config/app.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/app.yml b/config/app.yml index 34d044656..e3d9c2c69 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.6.0' + version: '4.7.0' development: <<: *shared diff --git a/package.json b/package.json index 38053b2c3..f38b87d80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.6.0", + "version": "4.7.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From 2180edc14a6e9e547e66348f30c68dc836a92c92 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:04:53 +0530 Subject: [PATCH 03/22] chore: Hide "Learn More" button in feature spotlight for self-hosted (#12675) --- .../pageComponents/emptyStates/AssistantPageEmptyState.vue | 3 +++ .../pageComponents/emptyStates/DocumentPageEmptyState.vue | 3 +++ .../pageComponents/emptyStates/ResponsePageEmptyState.vue | 3 +++ .../components-next/feature-spotlight/FeatureSpotlight.vue | 3 ++- .../feature-spotlight/FeatureSpotlightPopover.vue | 3 ++- .../dashboard/routes/dashboard/captain/assistants/Index.vue | 6 +++++- .../dashboard/routes/dashboard/captain/documents/Index.vue | 3 +++ .../dashboard/routes/dashboard/captain/responses/Index.vue | 3 +++ 8 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue index 365b78222..a4087d79a 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue @@ -1,4 +1,5 @@ + + diff --git a/app/javascript/dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue b/app/javascript/dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue new file mode 100644 index 000000000..617e5d0f8 --- /dev/null +++ b/app/javascript/dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue @@ -0,0 +1,74 @@ + + + diff --git a/app/javascript/dashboard/components-next/changelog-card/StackedChangelogCard.story.vue b/app/javascript/dashboard/components-next/changelog-card/StackedChangelogCard.story.vue new file mode 100644 index 000000000..26978b422 --- /dev/null +++ b/app/javascript/dashboard/components-next/changelog-card/StackedChangelogCard.story.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/javascript/dashboard/components-next/changelog-card/StackedChangelogCard.vue b/app/javascript/dashboard/components-next/changelog-card/StackedChangelogCard.vue new file mode 100644 index 000000000..8b2d430a4 --- /dev/null +++ b/app/javascript/dashboard/components-next/changelog-card/StackedChangelogCard.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index cef4346e9..edcb4106e 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -13,6 +13,7 @@ import { vOnClickOutside } from '@vueuse/components'; import Button from 'dashboard/components-next/button/Button.vue'; import SidebarGroup from './SidebarGroup.vue'; import SidebarProfileMenu from './SidebarProfileMenu.vue'; +import SidebarChangelogCard from './SidebarChangelogCard.vue'; import ChannelLeaf from './ChannelLeaf.vue'; import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue'; import Logo from 'next/icon/Logo.vue'; @@ -32,11 +33,15 @@ const emit = defineEmits([ 'closeMobileSidebar', ]); -const { accountScopedRoute } = useAccount(); +const { accountScopedRoute, isOnChatwootCloud } = useAccount(); const store = useStore(); const searchShortcut = useKbd([`$mod`, 'k']); const { t } = useI18n(); +const isACustomBrandedInstance = useMapGetter( + 'globalConfig/isACustomBrandedInstance' +); + const toggleShortcutModalFn = show => { if (show) { emit('openKeyShortcutModal'); @@ -532,20 +537,20 @@ const menuItems = computed(() => { ]" >
-
-
+
+
@@ -570,7 +575,7 @@ const menuItems = computed(() => {
-