From b146cf1d0e0bd15dd615556569a8b20cc9468c7f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:43:35 -0700 Subject: [PATCH] fix(email): thread attachment sends on the caller's reply_to send_document() accepted reply_to but never passed it down, so attachments always threaded from the cached per-address context (or not at all) even when the caller named the message to reply to. The plain-text path (_send_email) already honored it; the attachment path now does too. The metadata half of #10131 (send_image rejecting metadata=) was already fixed on main by the adapter parity pass. Diagnosis from #10131 and the explicit-reply_to-wins shape from PR #10321 (which targeted the pre-plugin path). Fixes #10131 Co-authored-by: LeonSGP43 --- plugins/platforms/email/adapter.py | 17 +++++++++++------ tests/gateway/test_email.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index 0839f19d5d..40a89a153a 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -703,9 +703,11 @@ class EmailAdapter(BasePlatformAdapter): logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject) return msg_id - def _send_with_files(self, to_addr: str, body: str, files: List[Tuple[Path, str]], *, lenient: bool) -> str: - """Send a reply with attachments; *lenient* logs-and-skips unattachable files instead of raising.""" - msg, msg_id, _ = self._new_reply(to_addr, body) + def _send_with_files(self, to_addr: str, body: str, files: List[Tuple[Path, str]], *, lenient: bool, + reply_to_msg_id: Optional[str] = None) -> str: + """Send a reply with attachments; *lenient* logs-and-skips unattachable files instead of raising. + An explicit *reply_to_msg_id* threads the mail like ``_send_email`` does (#10131).""" + msg, msg_id, _ = self._new_reply(to_addr, body, reply_to_msg_id) for path, name in files: try: _attach_file(msg, path, name) @@ -755,11 +757,14 @@ class EmailAdapter(BasePlatformAdapter): async def send_document(self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: """Send a file as an email attachment.""" - return await self._run_send(self._send_email_with_attachment, (chat_id, caption or "", file_path, file_name), "[Email] Send document failed: %s") + return await self._run_send(self._send_email_with_attachment, (chat_id, caption or "", file_path, file_name, reply_to), + "[Email] Send document failed: %s") - def _send_email_with_attachment(self, to_addr: str, body: str, file_path: str, file_name: Optional[str] = None) -> str: + def _send_email_with_attachment(self, to_addr: str, body: str, file_path: str, file_name: Optional[str] = None, + reply_to_msg_id: Optional[str] = None) -> str: """Send an email with a single file attachment via SMTP (raises if unattachable).""" - return self._send_with_files(to_addr, body, [(Path(file_path), file_name or Path(file_path).name)], lenient=False) + return self._send_with_files(to_addr, body, [(Path(file_path), file_name or Path(file_path).name)], lenient=False, + reply_to_msg_id=reply_to_msg_id) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return basic info about the email chat.""" diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 444ae94ea6..4d5d3f9ad3 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -432,6 +432,27 @@ class TestSendMethods(unittest.TestCase): os.unlink(tmp_path) + def test_send_document_threads_on_explicit_reply_to(self): + """An explicit reply_to wins over the cached thread context for attachment sends (#10131).""" + import asyncio + import tempfile + adapter = self._make_adapter() + adapter._thread_context["user@test.com"] = {"subject": "Old", "message_id": ""} + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + f.write(b"doc") + tmp_path = f.name + try: + with patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + result = asyncio.run(adapter.send_document("user@test.com", tmp_path, reply_to="")) + self.assertTrue(result.success) + sent_msg = mock_server.send_message.call_args[0][0] + self.assertEqual(sent_msg["In-Reply-To"], "") + self.assertEqual(sent_msg["References"], "") + finally: + os.unlink(tmp_path) + def test_get_chat_info(self): """get_chat_info should return email address as chat info.""" import asyncio