human hand off
This commit is contained in:
parent
d0fee5c8f7
commit
7af776196a
34 changed files with 2941 additions and 195 deletions
83
backend/tests/test_handoff_delivery.py
Normal file
83
backend/tests/test_handoff_delivery.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services import human_handoff_service
|
||||
from app.services.handoff_delivery import (
|
||||
STATUS_AMBIGUOUS,
|
||||
STATUS_PENDING,
|
||||
STATUS_SENT,
|
||||
process_handoff_delivery_once,
|
||||
)
|
||||
|
||||
|
||||
class FakeJobs:
|
||||
def __init__(self):
|
||||
self.job = {
|
||||
"_id": ObjectId(),
|
||||
"kind": "MIRROR_TO_HUMAN",
|
||||
"account_id": "account-1",
|
||||
"handoff_id": ObjectId(),
|
||||
"source_message_id": ObjectId(),
|
||||
"status": STATUS_PENDING,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"next_attempt_at": datetime.now(timezone.utc),
|
||||
}
|
||||
|
||||
async def find_one_and_update(self, query, update, **kwargs):
|
||||
if self.job["status"] != STATUS_PENDING:
|
||||
return None
|
||||
self.job["status"] = "SENDING"
|
||||
self.job["attempt_count"] = self.job.get("attempt_count", 0) + 1
|
||||
return dict(self.job)
|
||||
|
||||
async def update_one(self, query, update):
|
||||
self.job.update(update.get("$set", {}))
|
||||
|
||||
|
||||
class FakeMessages:
|
||||
async def update_one(self, query, update):
|
||||
return None
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self):
|
||||
self.handoff_delivery_jobs = FakeJobs()
|
||||
self.messages = FakeMessages()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_job_is_claimed_and_sent_once(monkeypatch):
|
||||
database = FakeDatabase()
|
||||
calls = 0
|
||||
|
||||
async def deliver(database, job):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return ObjectId()
|
||||
|
||||
monkeypatch.setattr(human_handoff_service, "deliver_handoff_job", deliver)
|
||||
|
||||
assert await process_handoff_delivery_once(database) is True
|
||||
assert await process_handoff_delivery_once(database) is False
|
||||
assert calls == 1
|
||||
assert database.handoff_delivery_jobs.job["status"] == STATUS_SENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_smtp_failure_is_not_automatically_retried(monkeypatch):
|
||||
database = FakeDatabase()
|
||||
calls = 0
|
||||
|
||||
async def deliver(database, job):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise TimeoutError("SMTP acknowledgement lost")
|
||||
|
||||
monkeypatch.setattr(human_handoff_service, "deliver_handoff_job", deliver)
|
||||
|
||||
assert await process_handoff_delivery_once(database) is True
|
||||
assert await process_handoff_delivery_once(database) is False
|
||||
assert calls == 1
|
||||
assert database.handoff_delivery_jobs.job["status"] == STATUS_AMBIGUOUS
|
||||
|
|
@ -1,41 +1,45 @@
|
|||
import pytest
|
||||
|
||||
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.handoff_mirror import load_mirror_payload
|
||||
from app.services.html_sanitizer import sanitize_email_html
|
||||
from app.services.mime_body_extractor import extract_mirror_content
|
||||
from app.services.mime_body_extractor import (
|
||||
extract_mirror_content,
|
||||
extract_new_reply_mime_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",
|
||||
@pytest.mark.asyncio
|
||||
async def test_mirror_body_is_unchanged_without_gentic_wrapper():
|
||||
payload = await load_mirror_payload(
|
||||
{
|
||||
"body_text": "one last attempt. i am tired",
|
||||
"body_html": "<p><strong>one last attempt.</strong> i am tired</p>",
|
||||
}
|
||||
)
|
||||
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
|
||||
assert payload.body_text == "one last attempt. i am tired"
|
||||
assert payload.body_html == (
|
||||
"<p><strong>one last attempt.</strong> i am tired</p>"
|
||||
)
|
||||
assert "Human Handoff" not in payload.body_text
|
||||
assert "gentic-handoff" not in (payload.body_html or "")
|
||||
|
||||
|
||||
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",
|
||||
@pytest.mark.asyncio
|
||||
async def test_mirror_plain_text_fallback_is_unchanged():
|
||||
payload = await load_mirror_payload(
|
||||
{
|
||||
"body_text": "one last attempt. i am tired",
|
||||
"body_html": None,
|
||||
}
|
||||
)
|
||||
assert "one last attempt. i am tired" in text
|
||||
assert "LATEST MESSAGE" not in text
|
||||
assert "Reply normally" in text
|
||||
assert payload.body_text == "one last attempt. i am tired"
|
||||
assert payload.body_html is None
|
||||
|
||||
|
||||
def test_format_sender_with_name():
|
||||
|
|
@ -83,3 +87,42 @@ def test_extract_html_from_multipart():
|
|||
extracted = extract_mirror_content(raw)
|
||||
assert "Invoice" in (extracted.body_html or "")
|
||||
assert "<table>" in (extracted.body_html or "")
|
||||
|
||||
|
||||
def test_rich_human_reply_keeps_new_html_and_removes_private_quote():
|
||||
raw = (
|
||||
b"From: owner@example.com\r\n"
|
||||
b"To: agent@gentic.email\r\n"
|
||||
b"Subject: Re: Finance handoff\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\n"
|
||||
b"Approved with urgency.\r\n\r\n"
|
||||
b"On Tue, Oct 6, 2026 at 6:00 AM agent@gentic.email wrote:\r\n"
|
||||
b"> private mirror\r\n"
|
||||
b"--b\r\nContent-Type: text/html; charset=utf-8\r\n\r\n"
|
||||
b'<div><strong>Approved</strong> with <em>urgency</em>.</div>'
|
||||
b'<div class="gmail_quote">private mirror</div>\r\n'
|
||||
b"--b--\r\n"
|
||||
)
|
||||
extracted = extract_new_reply_mime_content(raw)
|
||||
assert extracted.body_text == "Approved with urgency."
|
||||
assert "<strong>Approved</strong>" in (extracted.body_html or "")
|
||||
assert "private mirror" not in (extracted.body_html or "")
|
||||
|
||||
|
||||
def test_rich_human_reply_keeps_new_attachment():
|
||||
raw = (
|
||||
b"From: owner@example.com\r\n"
|
||||
b"To: agent@gentic.email\r\n"
|
||||
b"MIME-Version: 1.0\r\n"
|
||||
b'Content-Type: multipart/mixed; boundary="m"\r\n\r\n'
|
||||
b"--m\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nApproved\r\n"
|
||||
b"--m\r\nContent-Type: application/pdf\r\n"
|
||||
b'Content-Disposition: attachment; filename="approval.pdf"\r\n\r\nPDF\r\n'
|
||||
b"--m--\r\n"
|
||||
)
|
||||
extracted = extract_new_reply_mime_content(raw)
|
||||
assert extracted.body_text == "Approved"
|
||||
assert [part.filename for part in extracted.file_attachments] == [
|
||||
"approval.pdf"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from bson import ObjectId
|
||||
|
||||
from app.services.handoff_mirror_policy import (
|
||||
GENERATED_BY_AGENT,
|
||||
GENERATED_BY_CUSTOMER,
|
||||
GENERATED_BY_HANDOFF_BRIDGE,
|
||||
GENERATED_BY_RELAY_ACK,
|
||||
MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
|
||||
MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
MESSAGE_ORIGIN_AGENT,
|
||||
is_relay_acknowledgment_message,
|
||||
should_mirror_to_human,
|
||||
)
|
||||
|
|
@ -40,6 +42,27 @@ def test_should_not_mirror_human_origin():
|
|||
assert should_mirror_to_human(message) is False
|
||||
|
||||
|
||||
def test_should_mirror_agent_authored_external_reply():
|
||||
message = {
|
||||
"message_scope": "EXTERNAL",
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_AGENT,
|
||||
"message_origin": MESSAGE_ORIGIN_AGENT,
|
||||
}
|
||||
assert should_mirror_to_human(message) is True
|
||||
|
||||
|
||||
def test_should_not_echo_human_relay_back_to_owner():
|
||||
message = {
|
||||
"message_scope": "EXTERNAL",
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_HANDOFF_BRIDGE,
|
||||
"message_origin": MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
"generated_from_handoff_message_id": ObjectId(),
|
||||
}
|
||||
assert should_mirror_to_human(message) is False
|
||||
|
||||
|
||||
def test_is_relay_acknowledgment_detects_legacy_body():
|
||||
message = {
|
||||
"direction": "outbound",
|
||||
|
|
|
|||
423
backend/tests/test_handoff_runtime.py
Normal file
423
backend/tests/test_handoff_runtime.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.handoff_delivery import enqueue_handoff_delivery
|
||||
from app.services.handoff_mirror_policy import (
|
||||
GENERATED_BY_AGENT,
|
||||
GENERATED_BY_HANDOFF_BRIDGE,
|
||||
MESSAGE_ORIGIN_AGENT,
|
||||
MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
RELAY_STATUS_BLOCKED,
|
||||
should_mirror_to_human,
|
||||
)
|
||||
from app.services import human_handoff_service
|
||||
from app.services.human_handoff_service import (
|
||||
MESSAGE_SCOPE_EXTERNAL,
|
||||
MESSAGE_SCOPE_HANDOFF,
|
||||
build_handoff_subject,
|
||||
maybe_trigger_handoff,
|
||||
process_human_reply,
|
||||
resolve_handoff_from_inbound,
|
||||
)
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.external_reply_service import build_external_reply_plan
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
|
||||
def sort(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
async def to_list(self, length=None):
|
||||
return list(self.items)[:length]
|
||||
|
||||
|
||||
class FakeCollection:
|
||||
def __init__(self, items=None):
|
||||
self.items = list(items or [])
|
||||
self.inserted = []
|
||||
self.updated = []
|
||||
|
||||
async def find_one(self, query, projection=None):
|
||||
for item in self.items:
|
||||
if _matches(item, query):
|
||||
return item
|
||||
return None
|
||||
|
||||
def find(self, query=None):
|
||||
query = query or {}
|
||||
return FakeCursor([item for item in self.items if _matches(item, query)])
|
||||
|
||||
async def insert_one(self, document):
|
||||
document = dict(document)
|
||||
document.setdefault("_id", ObjectId())
|
||||
self.items.append(document)
|
||||
self.inserted.append(document)
|
||||
return SimpleNamespace(inserted_id=document["_id"])
|
||||
|
||||
async def update_one(self, query, update, upsert=False):
|
||||
self.updated.append((query, update, upsert))
|
||||
existing = await self.find_one(query)
|
||||
if existing is None and upsert:
|
||||
inserted = {"_id": ObjectId(), **query, **update.get("$setOnInsert", {})}
|
||||
self.items.append(inserted)
|
||||
return SimpleNamespace(upserted_id=inserted["_id"], modified_count=0)
|
||||
if existing is not None:
|
||||
existing.update(update.get("$set", {}))
|
||||
return SimpleNamespace(upserted_id=None, modified_count=1)
|
||||
return SimpleNamespace(upserted_id=None, modified_count=0)
|
||||
|
||||
async def find_one_and_update(self, query, update, **kwargs):
|
||||
existing = await self.find_one(query)
|
||||
if existing is None:
|
||||
return None
|
||||
existing.update(update.get("$set", {}))
|
||||
return existing
|
||||
|
||||
|
||||
def _matches(document, query):
|
||||
for key, expected in query.items():
|
||||
if key == "$or":
|
||||
if not any(_matches(document, clause) for clause in expected):
|
||||
return False
|
||||
continue
|
||||
if key == "$ne":
|
||||
continue
|
||||
actual = document.get(key)
|
||||
if isinstance(expected, dict):
|
||||
if "$ne" in expected and actual == expected["$ne"]:
|
||||
return False
|
||||
if "$in" in expected and actual not in expected["$in"]:
|
||||
return False
|
||||
continue
|
||||
if actual != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_external_mailbox_filter_excludes_handoff_scope():
|
||||
match = {"account_id": "account-1", "mailbox": "inbox"}
|
||||
apply_external_scope(match)
|
||||
assert match["$or"] == [
|
||||
{"message_scope": "EXTERNAL"},
|
||||
{"message_scope": {"$exists": False}},
|
||||
]
|
||||
|
||||
|
||||
def test_bridge_plan_never_references_private_ids():
|
||||
customer = {
|
||||
"_id": ObjectId(),
|
||||
"internet_message_id": "<A@gmail.com>",
|
||||
"message_id": "<A@gmail.com>",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"references": [],
|
||||
"subject": "Invoice 42",
|
||||
}
|
||||
private_id = "<H1@gentic.email>"
|
||||
plan = build_external_reply_plan(
|
||||
parent_external_message=customer,
|
||||
body_text="Approved",
|
||||
recipients=["customer@gmail.com"],
|
||||
)
|
||||
assert plan.threading.in_reply_to == "<A@gmail.com>"
|
||||
assert private_id not in plan.threading.references
|
||||
assert plan.subject == "Re: Invoice 42"
|
||||
assert "handoff" not in plan.subject.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_handoff_is_sticky_without_reevaluating_rules():
|
||||
external_thread_id = ObjectId()
|
||||
source_id = ObjectId()
|
||||
handoff = {
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"external_thread_id": external_thread_id,
|
||||
"status": "ACTIVE",
|
||||
"rule_name": "Finance approvals",
|
||||
}
|
||||
database = SimpleNamespace(
|
||||
human_handoffs=FakeCollection([handoff]),
|
||||
human_handoff_rules=FakeCollection(
|
||||
[
|
||||
{
|
||||
"_id": ObjectId(),
|
||||
"name": "Finance approvals",
|
||||
"priority": 1,
|
||||
"labels": ["Finance"],
|
||||
"email_types": ["transactional"],
|
||||
"instructions": "only invoices",
|
||||
"active": True,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
]
|
||||
),
|
||||
handoff_delivery_jobs=FakeCollection(),
|
||||
)
|
||||
message = {
|
||||
"_id": source_id,
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "inbound",
|
||||
"sender": {"address": "customer@example.com"},
|
||||
"subject": "Hello there",
|
||||
"body_text": "just checking in",
|
||||
"snippet": "just checking in",
|
||||
}
|
||||
|
||||
result = await maybe_trigger_handoff(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
external_message=message,
|
||||
external_thread_id=external_thread_id,
|
||||
)
|
||||
|
||||
assert result["_id"] == handoff["_id"]
|
||||
assert database.handoff_delivery_jobs.items
|
||||
assert (
|
||||
database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_delivery_jobs_share_an_idempotency_key():
|
||||
database = SimpleNamespace(handoff_delivery_jobs=FakeCollection())
|
||||
handoff_id = ObjectId()
|
||||
source_id = ObjectId()
|
||||
first = await enqueue_handoff_delivery(
|
||||
database,
|
||||
kind="MIRROR_TO_HUMAN",
|
||||
account_id="account-1",
|
||||
handoff_id=handoff_id,
|
||||
source_message_id=source_id,
|
||||
agent_email="agent@gentic.email",
|
||||
)
|
||||
second = await enqueue_handoff_delivery(
|
||||
database,
|
||||
kind="MIRROR_TO_HUMAN",
|
||||
account_id="account-1",
|
||||
handoff_id=handoff_id,
|
||||
source_message_id=source_id,
|
||||
agent_email="agent@gentic.email",
|
||||
)
|
||||
assert first["idempotency_key"] == second["idempotency_key"]
|
||||
assert len(database.handoff_delivery_jobs.items) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_human_reply_resolves_only_to_private_handoff():
|
||||
parent_id = ObjectId()
|
||||
thread_id = ObjectId()
|
||||
handoff = {
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"handoff_thread_id": thread_id,
|
||||
"human_email": "owner@example.com",
|
||||
"agent_email": "agent@gentic.email",
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
database = SimpleNamespace(
|
||||
messages=FakeCollection(
|
||||
[
|
||||
{
|
||||
"_id": parent_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
"message_id": "<H1@gentic.email>",
|
||||
"message_id_folded": "<h1@gentic.email>",
|
||||
}
|
||||
]
|
||||
),
|
||||
mailbox_entries=FakeCollection(
|
||||
[
|
||||
{
|
||||
"account_id": "account-1",
|
||||
"message_id": parent_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
]
|
||||
),
|
||||
human_handoffs=FakeCollection([handoff]),
|
||||
)
|
||||
|
||||
resolved = await resolve_handoff_from_inbound(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
sender_address="owner@example.com",
|
||||
in_reply_to="<H1@gentic.email>",
|
||||
references=["<H1@gentic.email>"],
|
||||
)
|
||||
assert resolved["_id"] == handoff["_id"]
|
||||
|
||||
|
||||
def test_human_echo_and_agent_mirror_policy():
|
||||
assert should_mirror_to_human(
|
||||
{
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_HANDOFF_BRIDGE,
|
||||
"message_origin": MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
"generated_from_handoff_message_id": ObjectId(),
|
||||
}
|
||||
) is False
|
||||
assert should_mirror_to_human(
|
||||
{
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_AGENT,
|
||||
"message_origin": MESSAGE_ORIGIN_AGENT,
|
||||
}
|
||||
) is True
|
||||
|
||||
|
||||
def test_private_subject_stays_off_the_external_plane():
|
||||
private = build_handoff_subject(
|
||||
rule_name="Finance approvals",
|
||||
external_sender="customer@example.com",
|
||||
external_subject="Invoice 42",
|
||||
)
|
||||
assert private.startswith("Finance approvals · handoff ·")
|
||||
assert "Invoice 42" in private
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_matching_rule_creates_a_new_linked_handoff():
|
||||
external_thread_id = ObjectId()
|
||||
source_id = ObjectId()
|
||||
rule = {
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"name": "Finance approvals",
|
||||
"priority": 1,
|
||||
"owner_email": "owner@example.com",
|
||||
"owner_name": "Owner",
|
||||
"labels": ["All mail"],
|
||||
"email_types": ["all"],
|
||||
"instructions": "",
|
||||
"active": True,
|
||||
"allow_replies": True,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
database = SimpleNamespace(
|
||||
human_handoffs=FakeCollection(),
|
||||
human_handoff_rules=FakeCollection([rule]),
|
||||
handoff_delivery_jobs=FakeCollection(),
|
||||
email_threads=FakeCollection(),
|
||||
)
|
||||
message = {
|
||||
"_id": source_id,
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "inbound",
|
||||
"sender": {"address": "customer@example.com"},
|
||||
"subject": "Invoice 42",
|
||||
"body_text": "please review this invoice",
|
||||
"snippet": "please review this invoice",
|
||||
}
|
||||
|
||||
result = await maybe_trigger_handoff(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
external_message=message,
|
||||
external_thread_id=external_thread_id,
|
||||
)
|
||||
|
||||
assert result["rule_name"] == "Finance approvals"
|
||||
assert result["external_thread_id"] == external_thread_id
|
||||
assert result["handoff_thread_id"]
|
||||
assert result["private_subject"].startswith("Finance approvals · handoff ·")
|
||||
assert database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_trigger_does_not_echo_human_origin_messages():
|
||||
database = SimpleNamespace(
|
||||
human_handoffs=FakeCollection(
|
||||
[
|
||||
{
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"external_thread_id": ObjectId(),
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
]
|
||||
),
|
||||
handoff_delivery_jobs=FakeCollection(),
|
||||
)
|
||||
result = await maybe_trigger_handoff(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
external_message={
|
||||
"_id": ObjectId(),
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_HANDOFF_BRIDGE,
|
||||
"message_origin": MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
"generated_from_handoff_message_id": ObjectId(),
|
||||
"sender": {"address": "agent@gentic.email"},
|
||||
},
|
||||
external_thread_id=database.human_handoffs.items[0]["external_thread_id"],
|
||||
)
|
||||
assert result is None
|
||||
assert database.handoff_delivery_jobs.items == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaked_private_subject_blocks_human_relay(monkeypatch):
|
||||
stored = {}
|
||||
|
||||
async def store(_database, **kwargs):
|
||||
stored.update(kwargs)
|
||||
return {"_id": ObjectId(), **kwargs}
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def enqueue(*args, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return {}
|
||||
|
||||
class BrokenStorage:
|
||||
async def get(self, *_args, **_kwargs):
|
||||
raise RuntimeError("raw mime unavailable")
|
||||
|
||||
monkeypatch.setattr(human_handoff_service, "store_handoff_message", store)
|
||||
monkeypatch.setattr(human_handoff_service, "enqueue_handoff_delivery", enqueue)
|
||||
monkeypatch.setattr(human_handoff_service, "get_raw_mime_storage", lambda: BrokenStorage())
|
||||
|
||||
database = SimpleNamespace(messages=FakeCollection(), human_handoffs=FakeCollection())
|
||||
await process_human_reply(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
handoff={
|
||||
"_id": ObjectId(),
|
||||
"handoff_thread_id": ObjectId(),
|
||||
"allow_replies": True,
|
||||
},
|
||||
parsed={
|
||||
"sender": {"name": "Owner", "address": "owner@example.com"},
|
||||
"to": [{"address": "agent@gentic.email"}],
|
||||
"subject": "Re: Finance approvals · handoff · customer@example.com · Invoice 42",
|
||||
"message_id": "<H2@gmail.com>",
|
||||
"in_reply_to": "<H1@gentic.email>",
|
||||
"references": ["<H1@gentic.email>"],
|
||||
"attachments": [],
|
||||
"raw_headers": {},
|
||||
},
|
||||
body_text=(
|
||||
"Finance approvals · handoff · customer@example.com · Invoice 42\n"
|
||||
"Please approve"
|
||||
),
|
||||
body_html=None,
|
||||
raw_mime={"bucket": "mail", "name": "raw.eml"},
|
||||
delivery_key="delivery-1",
|
||||
)
|
||||
assert stored["relay_status"] == RELAY_STATUS_BLOCKED
|
||||
assert enqueued == []
|
||||
|
|
@ -1,13 +1,74 @@
|
|||
from app.services.human_handoff_service import MESSAGE_SCOPE_EXTERNAL, MESSAGE_SCOPE_HANDOFF
|
||||
from app.services.human_handoff_service import (
|
||||
MESSAGE_SCOPE_EXTERNAL,
|
||||
MESSAGE_SCOPE_HANDOFF,
|
||||
build_handoff_subject,
|
||||
maybe_trigger_handoff,
|
||||
process_human_reply,
|
||||
redact_human_address,
|
||||
resolve_handoff_from_inbound,
|
||||
select_matching_handoff_rule,
|
||||
)
|
||||
|
||||
|
||||
def test_scope_constants_are_distinct():
|
||||
assert MESSAGE_SCOPE_EXTERNAL != MESSAGE_SCOPE_HANDOFF
|
||||
|
||||
|
||||
def test_human_handoff_runtime_is_disabled():
|
||||
import app.services.human_handoff_service as module
|
||||
def test_human_handoff_runtime_is_enabled():
|
||||
assert callable(maybe_trigger_handoff)
|
||||
assert callable(process_human_reply)
|
||||
assert callable(resolve_handoff_from_inbound)
|
||||
|
||||
assert not hasattr(module, "maybe_trigger_handoff")
|
||||
assert not hasattr(module, "process_human_reply")
|
||||
assert not hasattr(module, "resolve_handoff_from_inbound")
|
||||
|
||||
def test_private_subject_contains_rule_sender_and_external_subject():
|
||||
subject = build_handoff_subject(
|
||||
rule_name="Finance approvals",
|
||||
external_sender="customer@example.com",
|
||||
external_subject="Invoice 42",
|
||||
)
|
||||
assert subject == (
|
||||
"Finance approvals · handoff · customer@example.com · Invoice 42"
|
||||
)
|
||||
|
||||
|
||||
def test_private_subject_removes_header_line_breaks():
|
||||
subject = build_handoff_subject(
|
||||
rule_name="Urgent\r\nBcc: attacker@example.com",
|
||||
external_sender="customer@example.com",
|
||||
external_subject="Help",
|
||||
)
|
||||
assert "\r" not in subject
|
||||
assert "\n" not in subject
|
||||
|
||||
|
||||
def test_human_address_is_replaced_before_external_relay():
|
||||
value = "Regards,\nOwner <private.owner@example.com>"
|
||||
assert redact_human_address(
|
||||
value,
|
||||
human_email="private.owner@example.com",
|
||||
agent_email="agent@gentic.email",
|
||||
) == "Regards,\nOwner <agent@gentic.email>"
|
||||
|
||||
|
||||
def test_lowest_priority_matching_rule_wins():
|
||||
rules = [
|
||||
{
|
||||
"name": "Backup",
|
||||
"priority": 20,
|
||||
"labels": ["All mail"],
|
||||
"email_types": ["all"],
|
||||
"instructions": "",
|
||||
},
|
||||
{
|
||||
"name": "Primary",
|
||||
"priority": 1,
|
||||
"labels": ["All mail"],
|
||||
"email_types": ["all"],
|
||||
"instructions": "",
|
||||
},
|
||||
]
|
||||
selected = select_matching_handoff_rule(
|
||||
rules,
|
||||
email={"subject": "Hello", "body_text": "Need help"},
|
||||
)
|
||||
assert selected["name"] == "Primary"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class FakeMessages:
|
|||
|
||||
async def update_one(self, *args, **kwargs):
|
||||
self.updates += 1
|
||||
return SimpleNamespace(upserted_id=ObjectId(), modified_count=0)
|
||||
|
||||
async def find_one(self, *args, **kwargs):
|
||||
return {"_id": self.id}
|
||||
|
|
@ -33,6 +34,12 @@ class FakeEntries:
|
|||
|
||||
async def update_one(self, *args, **kwargs):
|
||||
self.updates += 1
|
||||
return SimpleNamespace(upserted_id=ObjectId(), modified_count=0)
|
||||
|
||||
|
||||
class FakeThreads:
|
||||
async def update_one(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
|
|
@ -84,7 +91,7 @@ async def test_rcpt_rejects_unknown_account(monkeypatch):
|
|||
async def test_rcpt_accepts_account_and_tracks_owner(monkeypatch):
|
||||
account = SimpleNamespace(id="account-1")
|
||||
monkeypatch.setattr(inbound_smtp, "SessionFactory", lambda: FakeSession(account))
|
||||
target = SimpleNamespace(rcpt_tos=[], rcpt_options=[])
|
||||
target = SimpleNamespace(rcpt_tos=[], rcpt_options=[], mail_from="sender@example.com")
|
||||
|
||||
response = await inbound_smtp.InboundHandler().handle_RCPT(
|
||||
None,
|
||||
|
|
@ -95,8 +102,8 @@ async def test_rcpt_accepts_account_and_tracks_owner(monkeypatch):
|
|||
)
|
||||
|
||||
assert response.startswith("250 2.1.5")
|
||||
assert target.rcpt_tos == ["user@gentic.email"]
|
||||
assert target.account_ids == {"user@gentic.email": "account-1"}
|
||||
assert target.rcpt_tos == ["user@getall.help"]
|
||||
assert target.account_ids == {"user@getall.help": "account-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -106,15 +113,98 @@ async def test_data_returns_success_only_after_idempotent_records(monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
inbound_smtp,
|
||||
"mongo_db",
|
||||
SimpleNamespace(messages=messages, mailbox_entries=entries),
|
||||
SimpleNamespace(
|
||||
messages=messages,
|
||||
mailbox_entries=entries,
|
||||
email_threads=FakeThreads(),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(inbound_smtp, "get_raw_mime_storage", lambda: FakeStorage())
|
||||
async def no_handoff(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(inbound_smtp, "resolve_handoff_from_inbound", no_handoff)
|
||||
monkeypatch.setattr(inbound_smtp, "maybe_trigger_handoff", no_handoff)
|
||||
async def ensure_thread(*args, **kwargs):
|
||||
return ObjectId()
|
||||
|
||||
monkeypatch.setattr(inbound_smtp, "ensure_thread_for_message", ensure_thread)
|
||||
|
||||
response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope())
|
||||
|
||||
assert response.startswith("250 2.0.0")
|
||||
assert messages.updates == 1
|
||||
assert entries.updates == 1
|
||||
assert messages.updates >= 1
|
||||
assert entries.updates >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_human_reply_stays_on_the_private_plane(monkeypatch):
|
||||
messages = FakeMessages()
|
||||
entries = FakeEntries()
|
||||
monkeypatch.setattr(
|
||||
inbound_smtp,
|
||||
"mongo_db",
|
||||
SimpleNamespace(
|
||||
messages=messages,
|
||||
mailbox_entries=entries,
|
||||
email_threads=FakeThreads(),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(inbound_smtp, "get_raw_mime_storage", lambda: FakeStorage())
|
||||
|
||||
async def resolve(*args, **kwargs):
|
||||
return {"_id": ObjectId()}
|
||||
|
||||
processed = []
|
||||
|
||||
async def process(*args, **kwargs):
|
||||
processed.append(kwargs)
|
||||
return {"_id": ObjectId()}
|
||||
|
||||
monkeypatch.setattr(inbound_smtp, "resolve_handoff_from_inbound", resolve)
|
||||
monkeypatch.setattr(inbound_smtp, "process_human_reply", process)
|
||||
|
||||
response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope())
|
||||
|
||||
assert response.startswith("250 2.0.0 Handoff message accepted")
|
||||
assert processed
|
||||
assert messages.updates == 0
|
||||
assert entries.updates == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_still_accepted_when_handoff_trigger_fails(monkeypatch):
|
||||
messages = FakeMessages()
|
||||
entries = FakeEntries()
|
||||
monkeypatch.setattr(
|
||||
inbound_smtp,
|
||||
"mongo_db",
|
||||
SimpleNamespace(
|
||||
messages=messages,
|
||||
mailbox_entries=entries,
|
||||
email_threads=FakeThreads(),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(inbound_smtp, "get_raw_mime_storage", lambda: FakeStorage())
|
||||
|
||||
async def no_handoff(*args, **kwargs):
|
||||
return None
|
||||
|
||||
async def boom(*args, **kwargs):
|
||||
raise RuntimeError("mirror queue unavailable")
|
||||
|
||||
monkeypatch.setattr(inbound_smtp, "resolve_handoff_from_inbound", no_handoff)
|
||||
monkeypatch.setattr(inbound_smtp, "maybe_trigger_handoff", boom)
|
||||
|
||||
async def ensure_thread(*args, **kwargs):
|
||||
return ObjectId()
|
||||
|
||||
monkeypatch.setattr(inbound_smtp, "ensure_thread_for_message", ensure_thread)
|
||||
|
||||
response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope())
|
||||
|
||||
assert response.startswith("250 2.0.0")
|
||||
assert messages.updates >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,7 +1,49 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.services.mail_sender import build_message, send_via_smtp, sign_message
|
||||
import pytest
|
||||
|
||||
from app.services.mail_sender import build_message, deliver_email, send_via_smtp, sign_message
|
||||
|
||||
|
||||
def test_build_message_formats_named_sender_header():
|
||||
message = build_message(
|
||||
sender="suyash@gentic.email",
|
||||
sender_name="Suyash Dixit",
|
||||
recipients=["recipient@example.com"],
|
||||
cc=[],
|
||||
subject="Named sender",
|
||||
body_text="Hello",
|
||||
body_html=None,
|
||||
)
|
||||
|
||||
assert message["From"] == "Suyash Dixit <suyash@gentic.email>"
|
||||
|
||||
|
||||
def test_build_message_preserves_preformatted_transactional_sender():
|
||||
message = build_message(
|
||||
sender="aGentic Email <notifications@gentic.email>",
|
||||
recipients=["recipient@example.com"],
|
||||
cc=[],
|
||||
subject="Welcome",
|
||||
body_text="Hello",
|
||||
body_html=None,
|
||||
)
|
||||
|
||||
assert message["From"] == "aGentic Email <notifications@gentic.email>"
|
||||
|
||||
|
||||
def test_build_message_adds_safe_fallback_sender_name():
|
||||
message = build_message(
|
||||
sender="notifications@gentic.email",
|
||||
recipients=["recipient@example.com"],
|
||||
cc=[],
|
||||
subject="Notification",
|
||||
body_text="Hello",
|
||||
body_html=None,
|
||||
)
|
||||
|
||||
assert message["From"] == "aGentic Email <notifications@gentic.email>"
|
||||
|
||||
|
||||
def test_dkim_sign_uses_selector_before_domain():
|
||||
|
|
@ -29,6 +71,7 @@ def test_dkim_sign_uses_selector_before_domain():
|
|||
assert signer.call_args.args[2] == b"ritoesis.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_via_smtp_uses_configured_client_hostname():
|
||||
with patch(
|
||||
"app.services.mail_sender.aiosmtplib.send",
|
||||
|
|
@ -43,6 +86,38 @@ async def test_send_via_smtp_uses_configured_client_hostname():
|
|||
assert send_mock.await_args.kwargs["local_hostname"] == "backend.gentic.email"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_email_uses_bare_address_for_smtp_envelope():
|
||||
domain = SimpleNamespace(
|
||||
domain_name="gentic.email",
|
||||
dkim_selector="agmail",
|
||||
dkim_private_key="private-key",
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"app.services.mail_sender.sign_message",
|
||||
return_value=b"signed",
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_sender.send_via_smtp",
|
||||
new_callable=AsyncMock,
|
||||
) as send_mock,
|
||||
):
|
||||
await deliver_email(
|
||||
sender="notifications@gentic.email",
|
||||
sender_name="aGentic Email",
|
||||
recipients=["user@example.com"],
|
||||
cc=[],
|
||||
bcc=[],
|
||||
subject="Welcome",
|
||||
body_text="Hello",
|
||||
body_html=None,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
assert send_mock.await_args.kwargs["sender"] == "notifications@gentic.email"
|
||||
|
||||
|
||||
def test_build_message_sets_in_reply_to_and_references():
|
||||
message = build_message(
|
||||
sender="suyash@gentic.email",
|
||||
|
|
|
|||
|
|
@ -99,6 +99,26 @@ def test_html_reply_preserves_nested_quoted_history():
|
|||
assert "> > sender: C" in result["quoted_content"]
|
||||
|
||||
|
||||
def test_apple_mail_attribution_is_treated_as_quote():
|
||||
body = """Approved.
|
||||
|
||||
On 6 Oct 2026, at 04:48, Suyash Dixit <suyash@gentic.email> wrote:
|
||||
sender: C"""
|
||||
result = extract_new_reply_content(body_text=body)
|
||||
assert result["new_reply_content"] == "Approved."
|
||||
assert "sender: C" in result["quoted_content"]
|
||||
|
||||
|
||||
def test_private_handoff_subject_fingerprint_is_blocked():
|
||||
from app.services.reply_extractor import validate_external_reply_body
|
||||
|
||||
leaked = (
|
||||
"Finance approvals · handoff · customer@example.com · Invoice 42\n"
|
||||
"Please approve"
|
||||
)
|
||||
assert validate_external_reply_body(leaked) is False
|
||||
|
||||
|
||||
def test_outlook_original_message_split():
|
||||
body = """Thanks for the update.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue