diff --git a/backend/services/email_parser.py b/backend/services/email_parser.py index 2d32454ad..66b2b0c25 100644 --- a/backend/services/email_parser.py +++ b/backend/services/email_parser.py @@ -2,13 +2,20 @@ from email.message import Message from pathlib import Path import datetime -from email.utils import formataddr, getaddresses +import re +from email.utils import getaddresses from email.utils import parsedate_to_datetime from typing import NotRequired, TypedDict from .attachment_parser import parse_email_attachment from .exceptions import EmailParseError from .text_safety import strip_html_markup +# Mirrors ``email.utils`` display-name quoting so an already-decoded display +# name keeps identical formatting for ASCII values while avoiding RFC 2047 +# re-encoding (see ``_format_address_display``). +_ADDRESS_SPECIALS_RE = re.compile(r'[][\\()<>@,:;".]') +_ADDRESS_ESCAPE_RE = re.compile(r'[\\"]') + class EmailData(TypedDict): """Parsed email data structure.""" @@ -39,13 +46,30 @@ def _sanitize_display_text(text: str) -> str: return strip_html_markup(_sanitize_nul(text)) +def _format_address_display(display_name: str, address: str) -> str: + """Render ``display_name
`` for a display field. + + Unlike ``email.utils.formataddr``, this never re-encodes a non-ASCII + display name into an RFC 2047 encoded-word: these fields are display + surfaces and RFC 2047 §6.2 requires encoded-words to be shown decoded. + Quoting/escaping matches ``formataddr`` so ASCII values are unchanged. + """ + if not display_name: + return address + escaped = _ADDRESS_ESCAPE_RE.sub(r"\\\g<0>", display_name) + quotes = '"' if _ADDRESS_SPECIALS_RE.search(display_name) else "" + return f"{quotes}{escaped}{quotes} <{address}>" + + def _sanitize_address_display_text(text: str) -> str: sanitized_parts: list[str] = [] for display_name, address in getaddresses([text]): safe_display_name = _sanitize_display_text(display_name).strip() safe_address = _sanitize_nul(address).strip() if safe_address: - sanitized_parts.append(formataddr((safe_display_name, safe_address))) + sanitized_parts.append( + _format_address_display(safe_display_name, safe_address) + ) elif safe_display_name: sanitized_parts.append(safe_display_name) if sanitized_parts: diff --git a/backend/services/threading_service.py b/backend/services/threading_service.py index 01c738432..c939f5470 100644 --- a/backend/services/threading_service.py +++ b/backend/services/threading_service.py @@ -107,7 +107,13 @@ async def assign_thread_id( Determine the thread_id for a new email based on in_reply_to and references. If no existing match is found, generate a new thread_id. """ - in_reply_to = normalize_message_id(email_data.get("in_reply_to")) + # RFC 5322 §3.6.4 defines In-Reply-To as ``1*msg-id``: it may carry more + # than one message-id. Parse it with the same angle-bracket extractor used + # for References and take the first parsed id as the immediate parent + # (jwz "message threading": extract the first message-id from In-Reply-To). + # Stripping ``<>`` off the whole header would mangle a multi-id value. + in_reply_to_ids = extract_reference_ids(email_data.get("in_reply_to")) + in_reply_to = in_reply_to_ids[0] if in_reply_to_ids else None references = extract_reference_ids(email_data.get("references")) existing_candidates = [] diff --git a/backend/tests/test_email_parser.py b/backend/tests/test_email_parser.py index e44d03127..572378408 100644 --- a/backend/tests/test_email_parser.py +++ b/backend/tests/test_email_parser.py @@ -381,6 +381,41 @@ def test_extract_thread_id_uses_first_reference_from_long_header(): assert _extract_thread_id(msg, "