diff --git a/static/js/document.js b/static/js/document.js
index b245ec852..0aadaa503 100644
--- a/static/js/document.js
+++ b/static/js/document.js
@@ -2378,20 +2378,27 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
// ── WYSIWYG email body helpers ──
+ function _emailPlainTextToHtml(text) {
+ const d = document.createElement('div');
+ d.textContent = text == null ? '' : String(text);
+ return d.innerHTML.replace(/\n/g, '
');
+ }
+
function _emailBodyToHtml(text) {
const t = (text || '').trim();
if (!t) return '';
// If it already contains a formatting/structural HTML tag, it's a saved
- // WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a
+ // WYSIWYG body — sanitize it before rendering. (Checking a leading '<' isn't enough: a
// rich body often starts with plain text, e.g. "Hi there".)
- if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t;
+ if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) {
+ return markdownModule.sanitizeAllowedHtml
+ ? markdownModule.sanitizeAllowedHtml(t)
+ : _emailPlainTextToHtml(t);
+ }
// Email body: keep author-typed `:shortcode:` text literal. Issue #345
// (shortcode → emoji) is scoped to chat; do not rewrite colons in mail.
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
- catch (_) {
- const d = document.createElement('div'); d.textContent = text;
- return d.innerHTML.replace(/\n/g, '
');
- }
+ catch (_) { return _emailPlainTextToHtml(text); }
}
// Mirror the rich body's plain text into the hidden textarea so the existing
// send / draft / change-detection plumbing (which reads the textarea) stays
diff --git a/static/js/markdown.js b/static/js/markdown.js
index f2bb7f85f..a3b27fbc8 100644
--- a/static/js/markdown.js
+++ b/static/js/markdown.js
@@ -133,7 +133,7 @@ function _cleanAllowedHtmlOnce(htmlString) {
return tpl.innerHTML;
}
-function sanitizeAllowedHtml(html) {
+export function sanitizeAllowedHtml(html) {
const raw = String(html == null ? '' : html);
// Non-browser context (e.g. a future SSR/Node import): fail closed by
// escaping rather than trusting the markup.
@@ -824,6 +824,7 @@ export function renderMermaid(container) {
const markdownModule = {
escapeHtml,
mdToHtml,
+ sanitizeAllowedHtml,
squashOutsideCode,
renderContent,
processWithThinking,
diff --git a/tests/test_markdown_dom_xss_helpers.py b/tests/test_markdown_dom_xss_helpers.py
index 25b18417d..db9ab9c9b 100644
--- a/tests/test_markdown_dom_xss_helpers.py
+++ b/tests/test_markdown_dom_xss_helpers.py
@@ -23,3 +23,16 @@ def test_markdown_raw_html_sanitizer_strips_scriptable_css():
assert "if (name === 'style')" in src
assert r"javascript:|vbscript:|data:|expression\(" in src
assert "el.removeAttribute(attr.name);" in src
+
+
+def test_email_rich_body_render_path_reuses_raw_html_sanitizer():
+ markdown_src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
+ document_src = (_REPO / "static" / "js" / "document.js").read_text(encoding="utf-8")
+ email_body_helper = document_src.split("function _emailBodyToHtml(text)", 1)[1].split(
+ " // Mirror the rich body's plain text", 1
+ )[0]
+
+ assert "export function sanitizeAllowedHtml(html)" in markdown_src
+ assert "sanitizeAllowedHtml," in markdown_src
+ assert "markdownModule.sanitizeAllowedHtml(t)" in email_body_helper
+ assert "return t;" not in email_body_helper