handoff fixes
This commit is contained in:
parent
6c52cd19cb
commit
89ddebdb60
15 changed files with 991 additions and 271 deletions
76
backend/tests/test_email_threading_roundtrip.py
Normal file
76
backend/tests/test_email_threading_roundtrip.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import pytest
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.thread_resolver import KnownMessage, resolve_thread_from_known
|
||||
|
||||
|
||||
def _known(
|
||||
internet_id: str,
|
||||
thread_id: ObjectId,
|
||||
*,
|
||||
references: list[str] | None = None,
|
||||
) -> KnownMessage:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.services.message_ids import normalize_message_id
|
||||
from app.services.subject_normalize import normalize_subject
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
return KnownMessage(
|
||||
id=ObjectId(),
|
||||
thread_id=thread_id,
|
||||
internet_message_id=normalize_message_id(internet_id) or "",
|
||||
account_id="acct-1",
|
||||
sent_at=now,
|
||||
received_at=now,
|
||||
created_at=now,
|
||||
participants=frozenset({"customer@gmail.com"}),
|
||||
normalized_subject=normalize_subject("lets try one more time"),
|
||||
)
|
||||
|
||||
|
||||
def test_gmail_reply_to_gentic_resolves_same_thread():
|
||||
thread_id = ObjectId()
|
||||
a1 = _known("<a1@gmail.com>", thread_id)
|
||||
g1 = _known("<g1@gentic.email>", thread_id, references=["<a1@gmail.com>"])
|
||||
known = {
|
||||
a1.internet_message_id: a1,
|
||||
g1.internet_message_id: g1,
|
||||
}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<g1@gentic.email>",
|
||||
references=["<a1@gmail.com>", "<g1@gentic.email>"],
|
||||
subject="Re: lets try one more time",
|
||||
participants={"customer@gmail.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id == thread_id
|
||||
assert resolution.thread_resolution_method == "in_reply_to"
|
||||
assert resolution.parent_message_id == g1.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookup_known_messages_awaits_aggregate():
|
||||
from app.services.thread_resolver import lookup_known_messages
|
||||
|
||||
class FakeCursor:
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
raise StopAsyncIteration
|
||||
|
||||
class FakeMessages:
|
||||
def __init__(self):
|
||||
self.aggregate_awaited = False
|
||||
|
||||
async def aggregate(self, pipeline):
|
||||
self.aggregate_awaited = True
|
||||
return FakeCursor()
|
||||
|
||||
messages = FakeMessages()
|
||||
database = type("DB", (), {"messages": messages})()
|
||||
result = await lookup_known_messages(database, "acct-1", ["<g1@gentic.email>"])
|
||||
assert result == {}
|
||||
assert messages.aggregate_awaited is True
|
||||
57
backend/tests/test_external_reply_service.py
Normal file
57
backend/tests/test_external_reply_service.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from bson import ObjectId
|
||||
|
||||
from app.services.external_reply_service import build_external_reply_plan
|
||||
from app.services.thread_service import threading_fields_from_parent
|
||||
|
||||
|
||||
def test_build_external_reply_references_gmail_parent():
|
||||
parent = {
|
||||
"_id": ObjectId(),
|
||||
"internet_message_id": "<gmail-a@mail.gmail.com>",
|
||||
"message_id": "<gmail-a@mail.gmail.com>",
|
||||
"references": [],
|
||||
"subject": "lets try one more time",
|
||||
}
|
||||
plan = build_external_reply_plan(
|
||||
parent_external_message=parent,
|
||||
body_text="I hear you.",
|
||||
recipients=["customer@gmail.com"],
|
||||
)
|
||||
assert plan.threading.in_reply_to == "<gmail-a@mail.gmail.com>"
|
||||
assert plan.threading.references == ["<gmail-a@mail.gmail.com>"]
|
||||
assert plan.subject == "Re: lets try one more time"
|
||||
assert plan.threading.replying_to_internet_message_id == "<gmail-a@mail.gmail.com>"
|
||||
|
||||
|
||||
def test_build_external_reply_chains_references_for_multi_round():
|
||||
parent = {
|
||||
"_id": ObjectId(),
|
||||
"internet_message_id": "<a2@gmail.com>",
|
||||
"message_id": "<a2@gmail.com>",
|
||||
"references": ["<a1@gmail.com>", "<g1@gentic.email>"],
|
||||
"subject": "Re: lets try one more time",
|
||||
}
|
||||
threading = threading_fields_from_parent(parent)
|
||||
assert threading["in_reply_to"] == "<a2@gmail.com>"
|
||||
assert threading["references"] == [
|
||||
"<a1@gmail.com>",
|
||||
"<g1@gentic.email>",
|
||||
"<a2@gmail.com>",
|
||||
]
|
||||
|
||||
|
||||
def test_external_reply_never_uses_internal_object_id_as_in_reply_to():
|
||||
parent = {
|
||||
"_id": ObjectId(),
|
||||
"internet_message_id": "<gmail-a@mail.gmail.com>",
|
||||
"message_id": "<gmail-a@mail.gmail.com>",
|
||||
"references": [],
|
||||
"subject": "hello",
|
||||
}
|
||||
plan = build_external_reply_plan(
|
||||
parent_external_message=parent,
|
||||
body_text="reply",
|
||||
recipients=["customer@gmail.com"],
|
||||
)
|
||||
assert str(parent["_id"]) not in (plan.threading.in_reply_to or "")
|
||||
assert all(str(parent["_id"]) not in ref for ref in plan.threading.references)
|
||||
85
backend/tests/test_handoff_mirror.py
Normal file
85
backend/tests/test_handoff_mirror.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from app.services.handoff_email_templates import (
|
||||
build_mirror_handoff_html,
|
||||
build_mirror_handoff_text,
|
||||
external_reply_subject,
|
||||
format_sender_line,
|
||||
handoff_email_subject,
|
||||
)
|
||||
from app.services.html_sanitizer import sanitize_email_html
|
||||
from app.services.mime_body_extractor import extract_mirror_content
|
||||
from app.services.thread_service import threading_fields_from_parent
|
||||
|
||||
|
||||
def test_mirror_template_is_minimal():
|
||||
html = build_mirror_handoff_html(
|
||||
sender_line="091rabbytes@gmail.com",
|
||||
external_subject="lets try one more time",
|
||||
original_html="<p>one last attempt. i am tired</p>",
|
||||
agent_email="suyash@gentic.email",
|
||||
)
|
||||
assert "LATEST MESSAGE" not in html
|
||||
assert "Why you" not in html
|
||||
assert "gentic-handoff-original" in html
|
||||
assert "one last attempt. i am tired" in html
|
||||
assert "✦ Human Handoff" in html
|
||||
assert "091rabbytes@gmail.com" in html
|
||||
assert "lets try one more time" in html
|
||||
|
||||
|
||||
def test_mirror_plain_text_template():
|
||||
text = build_mirror_handoff_text(
|
||||
sender_line="091rabbytes@gmail.com",
|
||||
external_subject="lets try one more time",
|
||||
original_body_text="one last attempt. i am tired",
|
||||
agent_email="suyash@gentic.email",
|
||||
)
|
||||
assert "one last attempt. i am tired" in text
|
||||
assert "LATEST MESSAGE" not in text
|
||||
assert "Reply normally" in text
|
||||
|
||||
|
||||
def test_format_sender_with_name():
|
||||
assert (
|
||||
format_sender_line({"name": "Rahul Sharma", "address": "rahul@example.com"})
|
||||
== "Rahul Sharma <rahul@example.com>"
|
||||
)
|
||||
|
||||
|
||||
def test_external_threading_headers_from_customer_message():
|
||||
parent = {
|
||||
"_id": "mongo-id",
|
||||
"message_id": "<a@gmail.com>",
|
||||
"references": [],
|
||||
}
|
||||
threading = threading_fields_from_parent(parent)
|
||||
assert threading["in_reply_to"] == "<a@gmail.com>"
|
||||
assert threading["references"] == ["<a@gmail.com>"]
|
||||
|
||||
|
||||
def test_external_reply_subject_uses_customer_subject():
|
||||
assert external_reply_subject("lets try one more time") == "Re: lets try one more time"
|
||||
assert handoff_email_subject("lets try one more time") == "Human Handoff · lets try one more time"
|
||||
|
||||
|
||||
def test_sanitize_removes_script():
|
||||
dirty = '<p>Hello</p><script>alert(1)</script><img src="x" onerror="alert(1)">'
|
||||
clean = sanitize_email_html(dirty)
|
||||
assert "<script" not in clean.lower()
|
||||
assert "onerror" not in clean.lower()
|
||||
assert "Hello" in clean
|
||||
|
||||
|
||||
def test_extract_html_from_multipart():
|
||||
raw = (
|
||||
b"From: sender@example.com\r\n"
|
||||
b"To: agent@gentic.email\r\n"
|
||||
b"Subject: Invoice approval\r\n"
|
||||
b'MIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary="b"\r\n\r\n'
|
||||
b"--b\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nPlain fallback\r\n"
|
||||
b"--b\r\nContent-Type: text/html; charset=utf-8\r\n\r\n"
|
||||
b"<h2>Invoice</h2><table><tr><td>Amount</td></tr></table>\r\n"
|
||||
b"--b--\r\n"
|
||||
)
|
||||
extracted = extract_mirror_content(raw)
|
||||
assert "Invoice" in (extracted.body_html or "")
|
||||
assert "<table>" in (extracted.body_html or "")
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from app.services.handoff_email_templates import external_reply_subject, handoff_email_subject
|
||||
from app.services.handoff_email_templates import external_reply_subject
|
||||
from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body
|
||||
|
||||
SCREENSHOT_REPLY = """ahan, did you receive a response?
|
||||
|
|
@ -49,10 +49,6 @@ def test_external_reply_subject_uses_customer_subject():
|
|||
assert external_reply_subject("Human Handoff · are you ready?") == "Re: are you ready?"
|
||||
|
||||
|
||||
def test_handoff_subject_format():
|
||||
assert handoff_email_subject("are you ready?") == "Human Handoff · are you ready?"
|
||||
|
||||
|
||||
def test_outlook_original_message_split():
|
||||
body = """Thanks for the update.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue