diff --git a/backend/app/services/human_handoff_service.py b/backend/app/services/human_handoff_service.py index fc6d95d..fd4e61f 100644 --- a/backend/app/services/human_handoff_service.py +++ b/backend/app/services/human_handoff_service.py @@ -25,7 +25,6 @@ from app.services.handoff_delivery import ( from app.services.handoff_email_templates import ( attach_quoted_history, build_gmail_quote, - format_sender_line, sender_display_name, ) from app.services.handoff_evaluator import evaluate_handoff @@ -206,16 +205,18 @@ async def _latest_thread_message( account_id: str, thread_id: ObjectId, scope: str, + exclude_message_ids: list | None = None, ) -> dict | None: + match = { + "account_id": account_id, + "thread_id": thread_id, + "message_scope": scope, + "deletion_state": {"$in": [None, ""]}, + } + if exclude_message_ids: + match["message_id"] = {"$nin": list(exclude_message_ids)} pipeline = [ - { - "$match": { - "account_id": account_id, - "thread_id": thread_id, - "message_scope": scope, - "deletion_state": {"$in": [None, ""]}, - } - }, + {"$match": match}, {"$sort": {"event_at": -1, "_id": -1}}, {"$limit": 1}, { @@ -720,18 +721,66 @@ async def process_human_reply( return human_message -def _mirror_from_identity(source: dict, agent_name: str) -> str: +def _mirror_from_identity( + source: dict, agent_name: str, agent_email: str +) -> tuple[str, str]: if source.get("direction") == "inbound": - return format_sender_line(source.get("sender") or {}) - return agent_name + sender = source.get("sender") or {} + address = (sender.get("address") or "").strip() + return sender_display_name(sender), address or agent_email + return agent_name, agent_email -def _mirror_body(source: dict, payload) -> tuple[str, str | None]: - new_text = (source.get("new_reply_content") or "").strip() - new_html = (source.get("new_reply_html") or "").strip() or None - if new_text: - return new_text, new_html - return payload.body_text, payload.body_html +def _body_has_quote_trail(body_text: str | None, body_html: str | None) -> bool: + html = body_html or "" + text = body_text or "" + if re.search(r"gmail_quote|blockquote", html, re.IGNORECASE): + return True + if re.search(r"(?m)^>+", text): + return True + if re.search(r"wrote:\s*$", text, re.IGNORECASE | re.MULTILINE): + return True + return "-----Original Message-----" in text + + +async def _mirror_body_with_trail( + database, + *, + account_id: str, + handoff: dict, + source: dict, + payload, +) -> tuple[str, str | None]: + text = payload.body_text or "" + html = payload.body_html + if _body_has_quote_trail(text, html): + return text, html or None + parent = await _latest_thread_message( + database, + account_id=account_id, + thread_id=handoff["external_thread_id"], + scope=MESSAGE_SCOPE_EXTERNAL, + exclude_message_ids=[source["_id"]], + ) + if parent is None: + return text, html + parent_payload = await load_mirror_payload(parent) + quoted_text, quoted_html = build_gmail_quote( + sender=parent.get("sender") or {}, + event_at=( + parent.get("sent_at") + or parent.get("received_at") + or parent.get("created_at") + ), + body_text=parent_payload.body_text or parent.get("body_text") or "", + body_html=parent_payload.body_html or parent.get("body_html"), + ) + return attach_quoted_history( + new_text=(source.get("new_reply_content") or text).strip() or text, + new_html=source.get("new_reply_html") or html, + quoted_text=quoted_text, + quoted_html=quoted_html, + ) async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> ObjectId: @@ -748,8 +797,16 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O job["account_id"], handoff["agent_email"] ) payload = await load_mirror_payload(source) - body_text, body_html = _mirror_body(source, payload) - from_name = _mirror_from_identity(source, sender_name) + body_text, body_html = await _mirror_body_with_trail( + database, + account_id=job["account_id"], + handoff=handoff, + source=source, + payload=payload, + ) + from_name, from_address = _mirror_from_identity( + source, sender_name, handoff["agent_email"] + ) parent = await _latest_thread_message( database, account_id=job["account_id"], @@ -761,7 +818,7 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O if parent and not re.match(r"^re:\s*", subject, re.IGNORECASE): subject = f"Re: {subject}" internet_message_id = await deliver_email( - sender=handoff["agent_email"], + sender=from_address, sender_name=from_name, recipients=[handoff["human_email"]], cc=[], @@ -770,6 +827,8 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O body_text=body_text, body_html=body_html, domain=domain, + reply_to=handoff["agent_email"], + envelope_sender=handoff["agent_email"], in_reply_to=threading.get("in_reply_to"), references=threading.get("references"), message_id=job["generated_message_id"], @@ -784,7 +843,7 @@ async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> O account_id=job["account_id"], thread_id=handoff["handoff_thread_id"], direction="outbound", - sender={"name": from_name, "address": handoff["agent_email"]}, + sender={"name": from_name, "address": from_address}, recipients=[ { "name": handoff.get("human_name", ""), diff --git a/backend/app/services/mail_sender.py b/backend/app/services/mail_sender.py index 02dd868..fc31065 100644 --- a/backend/app/services/mail_sender.py +++ b/backend/app/services/mail_sender.py @@ -237,6 +237,7 @@ async def deliver_email( message_id: str | None = None, inline_parts: list[ExtractedMimePart] | None = None, file_attachments: list[ExtractedMimePart] | None = None, + envelope_sender: str | None = None, ) -> str: _, sender_address = _sender_identity(sender, sender_name) message = build_message( @@ -259,7 +260,7 @@ async def deliver_email( log_signed_mime_headers(signed) await send_via_smtp( signed, - sender=sender_address, + sender=envelope_sender or sender_address, recipients=[*recipients, *cc, *bcc], ) return str(message["Message-ID"]) diff --git a/backend/tests/test_human_handoff_service.py b/backend/tests/test_human_handoff_service.py index b63ec37..11342e6 100644 --- a/backend/tests/test_human_handoff_service.py +++ b/backend/tests/test_human_handoff_service.py @@ -1,6 +1,10 @@ +import pytest + from app.services.human_handoff_service import ( MESSAGE_SCOPE_EXTERNAL, MESSAGE_SCOPE_HANDOFF, + _body_has_quote_trail, + _mirror_from_identity, build_handoff_subject, maybe_trigger_handoff, process_human_reply, @@ -20,6 +24,80 @@ def test_human_handoff_runtime_is_enabled(): assert callable(resolve_handoff_from_inbound) +@pytest.mark.asyncio +async def test_mirror_body_appends_external_history_when_missing(monkeypatch): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from app.services.human_handoff_service import _mirror_body_with_trail + + parent = { + "_id": "parent", + "sender": {"name": "Parth Dixit", "address": "parthdixit091@gmail.com"}, + "body_text": "sender: C\n\nOn earlier Suyash Dixit wrote:\n> responder: B", + "body_html": None, + "sent_at": None, + } + monkeypatch.setattr( + "app.services.human_handoff_service._latest_thread_message", + AsyncMock(return_value=parent), + ) + monkeypatch.setattr( + "app.services.human_handoff_service.load_mirror_payload", + AsyncMock(return_value=SimpleNamespace(body_text=parent["body_text"], body_html=None)), + ) + text, html = await _mirror_body_with_trail( + SimpleNamespace(), + account_id="account-1", + handoff={"external_thread_id": "thread"}, + source={"_id": "child", "new_reply_content": "responder: D", "new_reply_html": None}, + payload=SimpleNamespace(body_text="responder: D", body_html=None), + ) + assert text.startswith("responder: D") + assert "parthdixit091@gmail.com" in text + assert "> sender: C" in text + assert "responder: B" in text + assert html and "gmail_quote" in html + + +def test_quote_trail_is_detected_in_forwarded_history(): + assert _body_has_quote_trail( + "responder: B\n\nOn Tue, Oct 6, 2026 at 7:44 AM Suyash Dixit wrote:\n> sender: A", + None, + ) + assert _body_has_quote_trail( + "sender: C", + '
sender: C
sender: A
', + ) + assert _body_has_quote_trail("sender: E", None) is False + + +def test_mirror_from_uses_one_address_not_both(): + name, address = _mirror_from_identity( + { + "direction": "inbound", + "sender": { + "name": "Suyash Dixit", + "address": "suyashdixit091@gmail.com", + }, + }, + "Suyash Dixit", + "suyash@gentic.email", + ) + assert name == "Suyash Dixit" + assert address == "suyashdixit091@gmail.com" + assert "gentic.email" not in address + + agent_name, agent_address = _mirror_from_identity( + {"direction": "outbound", "sender": {"address": "suyash@gentic.email"}}, + "Suyash Dixit", + "suyash@gentic.email", + ) + assert agent_name == "Suyash Dixit" + assert agent_address == "suyash@gentic.email" + assert "gmail.com" not in agent_address + + def test_private_subject_contains_customer_name_and_external_subject(): subject = build_handoff_subject( sender_name="Parth Dixit",