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 <cine.dreamer.one@gmail.com>
This commit is contained in:
teknium1
2026-09-12 06:43:35 -07:00
committed by Teknium
parent b78abb4710
commit b146cf1d0e
2 changed files with 32 additions and 6 deletions

View File

@@ -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."""

View File

@@ -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": "<cached@test.com>"}
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="<explicit@test.com>"))
self.assertTrue(result.success)
sent_msg = mock_server.send_message.call_args[0][0]
self.assertEqual(sent_msg["In-Reply-To"], "<explicit@test.com>")
self.assertEqual(sent_msg["References"], "<explicit@test.com>")
finally:
os.unlink(tmp_path)
def test_get_chat_info(self):
"""get_chat_info should return email address as chat info."""
import asyncio