Compare commits

...

1 Commits

Author SHA1 Message Date
iamsivin
5ac95ce607 fix: preserve email signature separator with empty line 2026-08-31 18:45:53 +05:30
6 changed files with 80 additions and 2 deletions

View File

@@ -42,7 +42,7 @@ class Messages::MarkdownRendererService
def render_html
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
doc = CommonMarker.render_doc(MarkdownSetextEscape.call(@content), :DEFAULT, [:strikethrough])
markdown_renderer.render(doc)
end

View File

@@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer
def render_message(hardbreaks: false)
markdown_renderer = BaseMarkdownRenderer.new(options: hardbreaks ? [:HARDBREAKS] : :DEFAULT)
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough, :autolink])
doc = CommonMarker.render_doc(MarkdownSetextEscape.call(@content), :DEFAULT, [:strikethrough, :autolink])
html = markdown_renderer.render(doc)
render_as_html_safe(html)
end

View File

@@ -0,0 +1,15 @@
# cmark parses a dashes/equals line sitting directly under a hard-break
# backslash ("\" + newline) as a setext heading underline: the dashes vanish
# into markup and the stray "\" renders as heading text. The reply editor
# serializes an empty line above the "--" signature delimiter exactly that
# way. Escaping the underline keeps it literal text (matching the dashboard,
# which disables setext parsing), while intentional setext headings — no
# trailing backslash — stay untouched. Stored content can be CRLF, so both
# line endings are handled.
module MarkdownSetextEscape
UNDERLINE_AFTER_HARD_BREAK = /\\\r?\n(?=(?:-+|=+)[ \t]*\r?(?:\n|\z))/
def self.call(content)
content.gsub(UNDERLINE_AFTER_HARD_BREAK) { |hard_break| "#{hard_break}\\" }
end
end

View File

@@ -84,6 +84,14 @@ RSpec.describe ChatwootMarkdownRenderer do
expect(renderer.render_message.to_s).to eq("<p>Visit <a href=\"https://example.com\">https://example.com</a> for details</p>\n")
end
end
context 'with an empty line above the signature delimiter' do
let(:markdown_content) { "Answer\n\n\\\n--\n\nRegards" }
it 'keeps the delimiter and the empty line instead of rendering a setext heading' do
expect(rendered_message.to_s).to eq("<p>Answer</p>\n<p><br />\n--</p>\n<p>Regards</p>\n")
end
end
end
describe '#render_markdown_to_plain_text' do

View File

@@ -0,0 +1,48 @@
require 'rails_helper'
RSpec.describe MarkdownSetextEscape do
describe '.call' do
it 'escapes the delimiter when an empty line sits directly above it' do
content = "Answer\n\n\\\n--\n\nRegards"
expect(described_class.call(content)).to eq("Answer\n\n\\\n\\--\n\nRegards")
end
it 'handles CRLF line endings' do
content = "Answer\r\n\r\n\\\r\n--\r\n\r\nRegards"
expect(described_class.call(content)).to eq("Answer\r\n\r\n\\\r\n\\--\r\n\r\nRegards")
end
it 'escapes equals underlines the same way' do
expect(described_class.call("Answer\n\n\\\n==\n\nRegards")).to eq("Answer\n\n\\\n\\==\n\nRegards")
end
it 'escapes only the underline when several empty lines precede the delimiter' do
content = "Answer\n\n\\\n\\\n--\n\nRegards"
expect(described_class.call(content)).to eq("Answer\n\n\\\n\\\n\\--\n\nRegards")
end
it 'escapes a delimiter at the end of the content' do
expect(described_class.call("Answer\n\n\\\n--")).to eq("Answer\n\n\\\n\\--")
end
it 'leaves a plain delimiter without a preceding empty line untouched' do
content = "Answer\n\n--\n\nRegards"
expect(described_class.call(content)).to eq(content)
end
it 'leaves hard breaks followed by regular text untouched' do
content = "Thanks \\\nSivin | Chatwoot"
expect(described_class.call(content)).to eq(content)
end
it 'leaves intentional setext headings untouched' do
content = "Title\n---\n\nBody"
expect(described_class.call(content)).to eq(content)
end
it 'leaves thematic breaks untouched' do
content = "para\n\n---\n\npara2"
expect(described_class.call(content)).to eq(content)
end
end
end

View File

@@ -362,6 +362,13 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
result = described_class.new(content, channel_type).render
expect(result).to include('<del>strikethrough text</del>')
end
it 'keeps the signature delimiter when an empty line sits above it' do
content = "Answer\n\n\\\n--\n\nRegards"
result = described_class.new(content, channel_type).render
expect(result).to include("<p><br />\n--</p>")
expect(result).not_to include('<h2>')
end
end
context 'when channel is Channel::WebWidget' do