diff --git a/backend/app/indexer/mime.py b/backend/app/indexer/mime.py index a269cd5..7bab481 100644 --- a/backend/app/indexer/mime.py +++ b/backend/app/indexer/mime.py @@ -83,7 +83,14 @@ def _addresses(msg: EmailMessage, header: str) -> list[dict[str, str]]: def _html_to_text(html: str) -> str: """Derive plain text from HTML by stripping all tags (SPEC §6.4 nh3 strip).""" - return nh3.clean(html, tags=set(), attributes={}, strip_comments=True) + return nh3.clean( + html, + tags=set(), + attributes={}, + strip_comments=True, + # The document is not body text; unwrapped it would lead the snippet. + clean_content_tags={"script", "style", "title"}, + ) def _get_body_content(msg: EmailMessage, subtype: str) -> str | None: diff --git a/backend/app/indexer/sanitize.py b/backend/app/indexer/sanitize.py index 0b9526d..189bb19 100644 --- a/backend/app/indexer/sanitize.py +++ b/backend/app/indexer/sanitize.py @@ -17,7 +17,7 @@ import nh3 # Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a # render re-sanitizes stale content from the raw blob (SPEC §10.4). -SANITIZER_VERSION = 3 +SANITIZER_VERSION = 4 _ALLOWED_TAGS: set[str] = { # structure @@ -218,8 +218,10 @@ def sanitize_html( link_rel="noopener noreferrer nofollow", strip_comments=True, # nh3 strips <script>/<style> *content* by default; we allow <style> (above) - # and sanitize its CSS ourselves, so only script content is stripped here. - clean_content_tags={"script"}, + # and sanitize its CSS ourselves, so its content stays. <title> is dropped + # with its text: as a disallowed tag nh3 would unwrap it, and the document + # title ("mailer", the subject) would render as a line above the body. + clean_content_tags={"script", "title"}, attribute_filter=attribute_filter, ) cleaned = _sanitize_style_elements(cleaned, allow_remote_images, state) diff --git a/backend/tests/indexer/test_mime.py b/backend/tests/indexer/test_mime.py index 8a1b51f..f1d3140 100644 --- a/backend/tests/indexer/test_mime.py +++ b/backend/tests/indexer/test_mime.py @@ -75,6 +75,13 @@ def test_html_only_derives_text() -> None: assert "<b>" not in parsed.text_body # tags stripped for indexing text +def test_html_only_text_skips_document_title() -> None: + parsed = parse_message( + _build(text=None, html="<html><head><title>mailer

Hello

") + ) + assert parsed.snippet == "Hello" + + def test_multipart_alternative_prefers_html_for_display() -> None: parsed = parse_message(_build(text="text version", html="

html version

")) assert parsed.html_body is not None diff --git a/backend/tests/indexer/test_sanitize.py b/backend/tests/indexer/test_sanitize.py index b1d1fa1..9b9960a 100644 --- a/backend/tests/indexer/test_sanitize.py +++ b/backend/tests/indexer/test_sanitize.py @@ -17,6 +17,19 @@ def test_scripts_and_handlers_are_removed() -> None: assert "hi" in result.html +def test_document_title_is_not_rendered() -> None: + # Newsletters put "mailer" or the subject in ; unwrapping the tag would + # show it as a stray line above the body. Hidden preheaders keep their style. + html = ( + "<html><head><title>mailer" + 'preview

Hi

' + ) + result = sanitize_html(html, MID) + assert "mailer" not in result.html + assert 'preview' in result.html + assert "

Hi

" in result.html + + def test_remote_image_is_blocked_and_flagged() -> None: html = '

see this

' result = sanitize_html(html, MID)