import logging import re from datetime import datetime, timezone from bson import ObjectId from app.services.handoff_evaluator import evaluate_handoff from app.services.mail_sender import deliver_email from app.services.message_ids import build_references_header, normalize_message_id, parse_message_id_list from app.services.thread_service import ( attach_message_to_thread, message_event_at, threading_fields_from_parent, ) logger = logging.getLogger(__name__) MESSAGE_SCOPE_EXTERNAL = "EXTERNAL" MESSAGE_SCOPE_HANDOFF = "HANDOFF" THREAD_TYPE_EXTERNAL = "EXTERNAL" THREAD_TYPE_HUMAN_HANDOFF = "HUMAN_HANDOFF" HANDOFF_STATUS_ACTIVE = "ACTIVE" HANDOFF_STATUS_WAITING = "WAITING_FOR_HUMAN" HANDOFF_STATUS_HUMAN_REPLIED = "HUMAN_REPLIED" HANDOFF_STATUS_AGENT_REPLIED = "AGENT_REPLIED" OWNER_AGENT = "AGENT" OWNER_HUMAN = "HUMAN" LINK_EXTERNAL_TRIGGERED = "EXTERNAL_TRIGGERED_HANDOFF" LINK_EXTERNAL_RELAYED = "EXTERNAL_RELAYED_TO_HUMAN" LINK_HUMAN_TRIGGERED_EXTERNAL = "HUMAN_TRIGGERED_EXTERNAL_REPLY" LINK_EXTERNAL_UPDATE_RELAYED = "EXTERNAL_UPDATE_RELAYED_TO_HUMAN" def _now() -> datetime: return datetime.now(timezone.utc) def _snippet(text: str, limit: int = 240) -> str: return re.sub(r"\s+", " ", (text or "")).strip()[:limit] def _handoff_subject(external_subject: str) -> str: subject = (external_subject or "(No subject)").strip() if subject.lower().startswith("human handoff:"): return subject return f"Human Handoff: {subject}" def _build_handoff_body( *, external_sender: str, external_subject: str, external_body: str, handoff_reason: str, agent_email: str, ) -> str: return ( "Human Handoff\n\n" "A conversation needs your attention.\n\n" f"From\n{external_sender}\n\n" f"Subject\n{external_subject}\n\n" "Latest message\n\n" f"\"{external_body.strip()}\"\n\n" "Why this was handed off\n\n" f"{handoff_reason}\n\n" "────────────────────────────────────────\n\n" f"Reply to this email to respond to the original sender through {agent_email}.\n\n" "Your reply will be sent to the customer.\n\n" "────────────────────────────────────────\n\n" "Gentic Human Handoff" ) async def create_empty_handoff_thread( database, *, account_id: str, subject: str, ) -> ObjectId: now = _now() document = { "account_id": account_id, "thread_type": THREAD_TYPE_HUMAN_HANDOFF, "normalized_subject": subject, "created_at": now, "updated_at": now, "last_message_at": now, "latest_message_id": None, "message_count": 0, } result = await database.email_threads.insert_one(document) return result.inserted_id async def _record_message_link( database, *, handoff_id: ObjectId, external_message_id: ObjectId | None, handoff_message_id: ObjectId, relationship: str, ) -> None: await database.handoff_message_links.insert_one( { "handoff_id": handoff_id, "external_message_id": external_message_id, "handoff_message_id": handoff_message_id, "relationship": relationship, "created_at": _now(), } ) async def _latest_external_message(database, account_id: str, external_thread_id: ObjectId) -> dict | None: pipeline = [ { "$match": { "account_id": account_id, "thread_id": external_thread_id, "message_scope": MESSAGE_SCOPE_EXTERNAL, "deletion_state": {"$in": [None, ""]}, } }, {"$sort": {"event_at": -1, "_id": -1}}, {"$limit": 1}, { "$lookup": { "from": "messages", "localField": "message_id", "foreignField": "_id", "as": "message", } }, {"$unwind": "$message"}, ] cursor = await database.mailbox_entries.aggregate(pipeline) rows = [row async for row in cursor] return rows[0]["message"] if rows else None async def _latest_handoff_message(database, account_id: str, handoff_thread_id: ObjectId) -> dict | None: pipeline = [ { "$match": { "account_id": account_id, "thread_id": handoff_thread_id, "message_scope": MESSAGE_SCOPE_HANDOFF, } }, {"$sort": {"event_at": -1, "_id": -1}}, {"$limit": 1}, { "$lookup": { "from": "messages", "localField": "message_id", "foreignField": "_id", "as": "message", } }, {"$unwind": "$message"}, ] cursor = await database.mailbox_entries.aggregate(pipeline) rows = [row async for row in cursor] return rows[0]["message"] if rows else None async def find_active_handoff_for_external_thread( database, account_id: str, external_thread_id: ObjectId, ) -> dict | None: return await database.human_handoffs.find_one( { "account_id": account_id, "external_thread_id": external_thread_id, "status": {"$in": [HANDOFF_STATUS_ACTIVE, HANDOFF_STATUS_WAITING, HANDOFF_STATUS_HUMAN_REPLIED, HANDOFF_STATUS_AGENT_REPLIED]}, } ) async def resolve_handoff_from_inbound( database, *, account_id: str, agent_email: str, sender_address: str, in_reply_to: str | None, references: list[str], ) -> dict | None: sender = sender_address.lower() rules = await database.human_handoff_rules.find( {"account_id": account_id, "owner_email": sender, "active": True} ).to_list(length=10) if not rules: return None search_ids = [*([in_reply_to] if in_reply_to else []), *references] if not search_ids: return None for internet_id in reversed(parse_message_id_list(" ".join(search_ids))): message = await database.messages.find_one( { "message_id": internet_id, "message_scope": MESSAGE_SCOPE_HANDOFF, } ) if message is None: continue entry = await database.mailbox_entries.find_one( { "account_id": account_id, "message_id": message["_id"], "message_scope": MESSAGE_SCOPE_HANDOFF, } ) if entry is None: continue handoff = await database.human_handoffs.find_one( { "account_id": account_id, "handoff_thread_id": entry["thread_id"], "human_email": sender, "status": {"$ne": "CLOSED"}, } ) if handoff: return handoff return None async def store_handoff_message( database, *, account_id: str, agent_email: str, handoff_thread_id: ObjectId, direction: str, sender: dict, recipients: list[dict], subject: str, body_text: str, internet_message_id: str | None = None, in_reply_to: str | None = None, references: list[str] | None = None, handoff_id: ObjectId | None = None, link_relationship: str | None = None, external_message_id: ObjectId | None = None, event_at: datetime | None = None, ) -> dict: now = event_at or _now() document = { "direction": direction, "message_scope": MESSAGE_SCOPE_HANDOFF, "account_id": account_id, "sender": sender, "to": recipients, "subject": subject, "snippet": _snippet(body_text), "body_text": body_text, "body_html": None, "attachments": [], "created_at": now, "sent_at": now if direction == "outbound" else None, "received_at": now if direction == "inbound" else None, "status": "received" if direction == "inbound" else "accepted", "message_id": normalize_message_id(internet_message_id), "in_reply_to": normalize_message_id(in_reply_to), "references": references or [], } result = await database.messages.insert_one(document) document["_id"] = result.inserted_id await attach_message_to_thread( database, account_id=account_id, thread_id=handoff_thread_id, message_id=result.inserted_id, subject=subject, event_at=now, ) await database.mailbox_entries.insert_one( { "account_id": account_id, "message_id": result.inserted_id, "thread_id": handoff_thread_id, "mailbox": "handoff", "message_scope": MESSAGE_SCOPE_HANDOFF, "direction": direction, "sender": sender, "recipients": recipients, "subject": subject, "snippet": document["snippet"], "attachments": [], "status": document["status"], "is_read": direction == "outbound", "is_starred": False, "event_at": now, "created_at": now, "trashed_at": None, "deletion_state": None, } ) if handoff_id and link_relationship: await _record_message_link( database, handoff_id=handoff_id, external_message_id=external_message_id, handoff_message_id=result.inserted_id, relationship=link_relationship, ) return document async def send_handoff_to_human( database, *, account_id: str, agent_email: str, human_email: str, external_message: dict, external_thread_id: ObjectId, handoff_reason: str, domain, existing_handoff: dict | None = None, ) -> dict: external_sender = external_message.get("sender", {}) external_sender_text = external_sender.get("address") or "unknown sender" external_subject = external_message.get("subject", "") external_body = external_message.get("body_text") or external_message.get("snippet") or "" if existing_handoff: handoff_id = existing_handoff["_id"] handoff_thread_id = existing_handoff["handoff_thread_id"] parent = await _latest_handoff_message(database, account_id, handoff_thread_id) threading = threading_fields_from_parent(parent) if parent else {} subject = _handoff_subject(external_subject) body = _build_handoff_body( external_sender=external_sender_text, external_subject=external_subject, external_body=external_body, handoff_reason="New message in the linked conversation.", agent_email=agent_email, ) relationship = LINK_EXTERNAL_UPDATE_RELAYED else: handoff_thread_id = await create_empty_handoff_thread( database, account_id=account_id, subject=_handoff_subject(external_subject), ) threading = {} subject = _handoff_subject(external_subject) body = _build_handoff_body( external_sender=external_sender_text, external_subject=external_subject, external_body=external_body, handoff_reason=handoff_reason, agent_email=agent_email, ) handoff_doc = { "account_id": account_id, "agent_email": agent_email.lower(), "human_email": human_email.lower(), "external_thread_id": external_thread_id, "handoff_thread_id": handoff_thread_id, "external_subject": external_subject, "external_sender": external_sender_text, "handoff_reason": handoff_reason, "status": HANDOFF_STATUS_WAITING, "current_owner": OWNER_HUMAN, "created_at": _now(), "updated_at": _now(), "last_external_message_at": message_event_at(external_message), "last_human_message_at": None, } insert = await database.human_handoffs.insert_one(handoff_doc) handoff_id = insert.inserted_id relationship = LINK_EXTERNAL_TRIGGERED internet_message_id = await deliver_email( sender=agent_email, recipients=[human_email], cc=[], bcc=[], subject=subject, body_text=body, body_html=None, domain=domain, in_reply_to=threading.get("in_reply_to"), references=threading.get("references"), ) handoff_message = await store_handoff_message( database, account_id=account_id, agent_email=agent_email, handoff_thread_id=handoff_thread_id, direction="outbound", sender={"name": "Gentic Agent", "address": agent_email}, recipients=[{"name": human_email.split("@")[0], "address": human_email}], subject=subject, body_text=body, internet_message_id=internet_message_id, in_reply_to=threading.get("in_reply_to"), references=threading.get("references"), handoff_id=handoff_id, link_relationship=relationship, external_message_id=external_message["_id"], ) await database.human_handoffs.update_one( {"_id": handoff_id}, { "$set": { "status": HANDOFF_STATUS_WAITING, "current_owner": OWNER_HUMAN, "updated_at": _now(), "last_external_message_at": message_event_at(external_message), "latest_handoff_message_id": handoff_message["_id"], } }, ) logger.info( "handoff_sent handoff_id=%s external_thread=%s handoff_thread=%s", handoff_id, external_thread_id, handoff_thread_id, ) return await database.human_handoffs.find_one({"_id": handoff_id}) async def _handoff_rule_for_human(database, account_id: str, human_email: str) -> dict | None: return await database.human_handoff_rules.find_one( { "account_id": account_id, "owner_email": human_email.lower(), "active": True, } ) async def process_human_reply( database, *, account_id: str, agent_email: str, handoff: dict, inbound_parsed: dict, inbound_body: str, domain, ) -> None: handoff_thread_id = handoff["handoff_thread_id"] external_thread_id = handoff["external_thread_id"] human_email = handoff["human_email"] rule = await _handoff_rule_for_human(database, account_id, human_email) allow_replies = rule.get("allow_replies", True) if rule else True human_message = await store_handoff_message( database, account_id=account_id, agent_email=agent_email, handoff_thread_id=handoff_thread_id, direction="inbound", sender={"name": human_email.split("@")[0], "address": human_email}, recipients=[{"name": "Gentic Agent", "address": agent_email}], subject=inbound_parsed.get("subject", ""), body_text=inbound_body, internet_message_id=inbound_parsed.get("message_id"), in_reply_to=inbound_parsed.get("in_reply_to"), references=inbound_parsed.get("references", []), handoff_id=handoff["_id"], link_relationship=LINK_HUMAN_TRIGGERED_EXTERNAL, ) now = _now() if not allow_replies: await database.human_handoffs.update_one( {"_id": handoff["_id"]}, { "$set": { "status": HANDOFF_STATUS_HUMAN_REPLIED, "current_owner": OWNER_AGENT, "updated_at": now, "last_human_message_at": now, } }, ) return external_parent = await _latest_external_message(database, account_id, external_thread_id) if external_parent is None: logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"]) return threading = threading_fields_from_parent(external_parent) external_subject = handoff.get("external_subject") or external_parent.get("subject", "") if not re.match(r"^re:\s*", external_subject, flags=re.IGNORECASE): external_subject = f"Re: {external_subject}" external_recipient = handoff.get("external_sender") or external_parent.get("sender", {}).get("address") if not external_recipient: return customer_body = inbound_body.strip() internet_message_id = await deliver_email( sender=agent_email, recipients=[external_recipient], cc=[], bcc=[], subject=external_subject, body_text=customer_body, body_html=None, domain=domain, in_reply_to=threading.get("in_reply_to"), references=threading.get("references"), ) external_doc = { "direction": "outbound", "message_scope": MESSAGE_SCOPE_EXTERNAL, "account_id": account_id, "sender": {"name": "Gentic Agent", "address": agent_email}, "to": [{"name": external_recipient.split("@")[0], "address": external_recipient}], "subject": external_subject, "snippet": _snippet(customer_body), "body_text": customer_body, "body_html": None, "attachments": [], "created_at": now, "sent_at": now, "status": "accepted", "message_id": normalize_message_id(internet_message_id), "in_reply_to": threading.get("in_reply_to"), "references": threading.get("references", []), "parent_message_id": external_parent["_id"], "thread_relation": "reply", "thread_resolution_method": "human_handoff_bridge", } ext_result = await database.messages.insert_one(external_doc) external_doc["_id"] = ext_result.inserted_id await attach_message_to_thread( database, account_id=account_id, thread_id=external_thread_id, message_id=ext_result.inserted_id, subject=external_subject, event_at=now, ) await database.mailbox_entries.insert_one( { "account_id": account_id, "message_id": ext_result.inserted_id, "thread_id": external_thread_id, "mailbox": "sent", "message_scope": MESSAGE_SCOPE_EXTERNAL, "direction": "outbound", "sender": external_doc["sender"], "recipients": external_doc["to"], "subject": external_subject, "snippet": external_doc["snippet"], "attachments": [], "status": "accepted", "is_read": True, "is_starred": False, "event_at": now, "created_at": now, "trashed_at": None, "deletion_state": None, } ) relay_body = f"Sent to customer:\n\n\"{customer_body}\"" relay_subject = f"Re: {_handoff_subject(handoff.get('external_subject', ''))}" relay_threading = threading_fields_from_parent(human_message) relay_internet_id = await deliver_email( sender=agent_email, recipients=[human_email], cc=[], bcc=[], subject=relay_subject, body_text=relay_body, body_html=None, domain=domain, in_reply_to=relay_threading.get("in_reply_to"), references=relay_threading.get("references"), ) await store_handoff_message( database, account_id=account_id, agent_email=agent_email, handoff_thread_id=handoff_thread_id, direction="outbound", sender={"name": "Gentic Agent", "address": agent_email}, recipients=[{"name": human_email.split("@")[0], "address": human_email}], subject=relay_subject, body_text=relay_body, internet_message_id=relay_internet_id, in_reply_to=relay_threading.get("in_reply_to"), references=relay_threading.get("references"), handoff_id=handoff["_id"], link_relationship=LINK_EXTERNAL_RELAYED, external_message_id=ext_result.inserted_id, ) await database.human_handoffs.update_one( {"_id": handoff["_id"]}, { "$set": { "status": HANDOFF_STATUS_AGENT_REPLIED, "current_owner": OWNER_AGENT, "updated_at": now, "last_human_message_at": now, } }, ) async def maybe_trigger_handoff( database, *, account_id: str, agent_email: str, external_message: dict, external_thread_id: ObjectId, domain, ) -> None: rules = await database.human_handoff_rules.find( {"account_id": account_id, "active": True} ).to_list(length=20) if not rules: return body_text = external_message.get("body_text") or external_message.get("snippet") or "" email_payload = { "subject": external_message.get("subject", ""), "snippet": external_message.get("snippet", ""), "body_text": body_text, "is_starred": False, } for rule in rules: evaluation = evaluate_handoff( email=email_payload, labels=rule.get("labels", []), email_types=rule.get("email_types", []), instructions=rule.get("instructions", ""), ) if not evaluation["would_handoff"]: continue if not rule.get("allow_replies", True): continue existing = await find_active_handoff_for_external_thread( database, account_id, external_thread_id ) reason = evaluation.get("instruction_reason") or rule.get("instructions") or "Matched handoff rules." await send_handoff_to_human( database, account_id=account_id, agent_email=agent_email, human_email=rule["owner_email"], external_message=external_message, external_thread_id=external_thread_id, handoff_reason=reason, domain=domain, existing_handoff=existing, ) return