Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions plugins/platforms/email/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,43 @@

SMTP_CONNECT_TIMEOUT = 30

_MESSAGE_ID_RE = re.compile(r"<[^<>\r\n]+>")


def _message_id_tokens(value: str) -> List[str]:
"""Return sanitized Message-ID tokens from a header value.

``References`` headers may be folded across multiple lines by mail clients
or IMAP servers. The email package rejects header values containing raw
newlines, so outbound replies must unfold and sanitize the inbound chain
before re-emitting it.
"""
if not value:
return []
raw = str(value)
matches = _MESSAGE_ID_RE.findall(raw)
if matches:
return [" ".join(match.split()) for match in matches]
unfolded = " ".join(raw.split())
return [part for part in unfolded.split(" ") if part]


def _build_references_header(existing: str, current_msg_id: str) -> str:
"""Build a safe outbound ``References`` chain preserving order.

The current inbound ``Message-ID`` is appended only if it is not already in
the inbound ``References`` chain.
"""
tokens: List[str] = []
seen: set[str] = set()
for candidate in _message_id_tokens(existing) + _message_id_tokens(current_msg_id):
key = candidate.lower()
if key in seen:
continue
seen.add(key)
tokens.append(candidate)
return " ".join(tokens)


def _create_ipv4_connection(
host: str,
Expand Down Expand Up @@ -686,6 +723,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
message_id = msg.get("Message-ID", "")
in_reply_to = msg.get("In-Reply-To", "")
references = msg.get("References", "")
# Skip automated/noreply senders before any processing
msg_headers = dict(msg.items())
if _is_automated_sender(sender_addr, msg_headers):
Expand All @@ -712,6 +750,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
"subject": subject,
"message_id": message_id,
"in_reply_to": in_reply_to,
"references": references,
"body": body,
"attachments": attachments,
"date": msg.get("Date", ""),
Expand Down Expand Up @@ -839,6 +878,7 @@ async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None:
self._thread_context[sender_addr] = {
"subject": subject,
"message_id": msg_data["message_id"],
"references": msg_data.get("references", ""),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value is still stored under sender_addr, while every send path later retrieves context by recipient address. A second, unrelated thread from the same sender overwrites this References chain before the first thread's response is sent. Please key/route the context by a stable email-thread identity and add a same-sender/two-threads regression.

}

source = self.build_source(
Expand Down Expand Up @@ -902,7 +942,9 @@ def _send_email(
original_msg_id = reply_to_msg_id or ctx.get("message_id")
if original_msg_id:
msg["In-Reply-To"] = original_msg_id
msg["References"] = original_msg_id
references = _build_references_header(ctx.get("references", ""), original_msg_id)
if references:
msg["References"] = references

msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
Expand Down Expand Up @@ -1015,7 +1057,9 @@ def _send_email_with_attachments(
original_msg_id = ctx.get("message_id")
if original_msg_id:
msg["In-Reply-To"] = original_msg_id
msg["References"] = original_msg_id
references = _build_references_header(ctx.get("references", ""), original_msg_id)
if references:
msg["References"] = references

msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
Expand Down Expand Up @@ -1095,7 +1139,9 @@ def _send_email_with_attachment(
original_msg_id = ctx.get("message_id")
if original_msg_id:
msg["In-Reply-To"] = original_msg_id
msg["References"] = original_msg_id
references = _build_references_header(ctx.get("references", ""), original_msg_id)
if references:
msg["References"] = references

msg["Date"] = formatdate(localtime=True)
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
Expand Down
85 changes: 85 additions & 0 deletions tests/test_email_threading_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from gateway.config import PlatformConfig
from plugins.platforms.email.adapter import EmailAdapter


class FakeSMTP:
def __init__(self):
self.sent_message = None

def login(self, address, password):
self.login_args = (address, password)

def send_message(self, msg):
self.sent_message = msg

def quit(self):
pass


def _adapter(monkeypatch):
monkeypatch.setenv("EMAIL_ADDRESS", "hermes@example.com")
monkeypatch.setenv("EMAIL_PASSWORD", "secret")
monkeypatch.setenv("EMAIL_IMAP_HOST", "imap.example.com")
monkeypatch.setenv("EMAIL_SMTP_HOST", "smtp.example.com")
return EmailAdapter(PlatformConfig(enabled=True))


def test_email_replies_preserve_references_chain(monkeypatch):
adapter = _adapter(monkeypatch)
fake = FakeSMTP()
monkeypatch.setattr(adapter, "_connect_smtp", lambda: fake)

adapter._thread_context["sender@example.com"] = {
"subject": "Re: Existing thread",
"message_id": "<current-inbound@example.com>",
"references": "<root@example.com> <previous-agent@example.com>",
}

adapter._send_email("sender@example.com", "reply body")

assert fake.sent_message is not None
assert fake.sent_message["In-Reply-To"] == "<current-inbound@example.com>"
assert (
fake.sent_message["References"]
== "<root@example.com> <previous-agent@example.com> <current-inbound@example.com>"
)


def test_email_replies_fallback_to_current_message_id_without_references(monkeypatch):
adapter = _adapter(monkeypatch)
fake = FakeSMTP()
monkeypatch.setattr(adapter, "_connect_smtp", lambda: fake)

adapter._thread_context["sender@example.com"] = {
"subject": "New thread",
"message_id": "<current-inbound@example.com>",
}

adapter._send_email("sender@example.com", "reply body")

assert fake.sent_message is not None
assert fake.sent_message["In-Reply-To"] == "<current-inbound@example.com>"
assert fake.sent_message["References"] == "<current-inbound@example.com>"


def test_email_replies_unfold_and_deduplicate_references(monkeypatch):
adapter = _adapter(monkeypatch)
fake = FakeSMTP()
monkeypatch.setattr(adapter, "_connect_smtp", lambda: fake)

adapter._thread_context["sender@example.com"] = {
"subject": "Re: Existing thread",
"message_id": "<current-inbound@example.com>",
"references": (
"<root@example.com>\r\n"
" <previous-agent@example.com>\r\n"
" <current-inbound@example.com>"
),
}

adapter._send_email("sender@example.com", "reply body")

assert fake.sent_message is not None
assert fake.sent_message["References"] == (
"<root@example.com> <previous-agent@example.com> <current-inbound@example.com>"
)