import re from datetime import datetime, timezone from bson import ObjectId from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import EmailStr, Field from pymongo.errors import DuplicateKeyError from app.database import get_mongo_database from app.models import Account from app.routers.auth import get_current_account from app.schemas import ( HandoffRuleOrderUpdate, HandoffRuleCreate, HandoffRuleRead, HandoffRuleUpdate, HandoffTestRequest, HandoffTestResult, HandoffConversationRead, HandoffConversationSummary, HandoffLinkedExternal, HandoffMessageRead, ) from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff from app.services.handoff_delivery import ( STATUS_AMBIGUOUS, STATUS_FAILED, STATUS_PENDING, ) router = APIRouter(prefix="/agent/handoff", tags=["agent-handoff"]) HANDOFF_MAILBOXES = {"handoff_inbox", "handoff_outbox"} def _now() -> datetime: return datetime.now(timezone.utc) def _owner_name_from_email(email: str) -> str: local = email.split("@", 1)[0] return re.sub(r"[._-]+", " ", local).strip().title() or email def _serialize(document: dict) -> HandoffRuleRead: return HandoffRuleRead( id=str(document["_id"]), name=document.get("name") or document.get("owner_name") or "Human handoff", owner_email=document["owner_email"], owner_name=document.get("owner_name") or _owner_name_from_email(document["owner_email"]), priority=document.get("priority", 0), labels=document.get("labels", []), email_types=document.get("email_types", []), instructions=document.get("instructions", ""), allow_replies=document.get("allow_replies", True), reply_as=document.get("reply_as", "agent"), active=document.get("active", True), created_at=document["created_at"], updated_at=document["updated_at"], ) @router.get("", response_model=list[HandoffRuleRead]) async def list_handoff_rules( database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): cursor = database.human_handoff_rules.find( {"account_id": str(account.id)} ).sort([("priority", 1), ("created_at", 1), ("_id", 1)]) return [_serialize(document) async for document in cursor] def _handoff_status_label(value: str) -> str: return { "ACTIVE": "Open", "WAITING_FOR_HUMAN": "Waiting for human", "HUMAN_REPLIED": "Human replied", "CLOSED": "Resolved", }.get(value, value.replace("_", " ").title()) @router.get("/conversations", response_model=list[HandoffConversationSummary]) async def list_handoff_conversations( mailbox: str = Query(default="handoff_inbox"), database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): if mailbox not in HANDOFF_MAILBOXES: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid handoff mailbox.", ) account_id = str(account.id) cursor = database.mailbox_entries.find( { "account_id": account_id, "message_scope": "HANDOFF", "mailbox": mailbox, "deletion_state": {"$in": [None, ""]}, } ).sort([("event_at", -1), ("_id", -1)]) seen: set[ObjectId] = set() items: list[HandoffConversationSummary] = [] async for entry in cursor: thread_id = entry.get("thread_id") if not thread_id or thread_id in seen: continue seen.add(thread_id) handoff = await database.human_handoffs.find_one( { "account_id": account_id, "handoff_thread_id": thread_id, } ) message = await database.messages.find_one({"_id": entry["message_id"]}) thread = await database.email_threads.find_one({"_id": thread_id}) if not handoff or not message: continue items.append( HandoffConversationSummary( id=str(handoff["_id"]), thread_id=str(thread_id), mailbox=mailbox, rule_name=handoff.get("rule_name", "Human handoff"), external_subject=handoff.get("external_subject", ""), external_sender=handoff.get("external_sender", ""), human_email=handoff.get("human_email", ""), human_name=handoff.get("human_name", ""), status=handoff.get("status", "ACTIVE"), status_label=_handoff_status_label( handoff.get("status", "ACTIVE") ), current_owner=handoff.get("current_owner", "HUMAN"), snippet=message.get("new_reply_content") or message.get("snippet", ""), message_count=(thread or {}).get("message_count", 1), updated_at=entry.get("event_at") or handoff.get("updated_at") or _now(), ) ) return items @router.get("/conversations/counts") async def handoff_conversation_counts( database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): account_id = str(account.id) result = {} for mailbox in sorted(HANDOFF_MAILBOXES): thread_ids = await database.mailbox_entries.distinct( "thread_id", { "account_id": account_id, "message_scope": "HANDOFF", "mailbox": mailbox, "deletion_state": {"$in": [None, ""]}, }, ) result[mailbox] = len([value for value in thread_ids if value]) return result @router.post("/deliveries/{job_id}/retry") async def retry_handoff_delivery( job_id: str, database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): if not ObjectId.is_valid(job_id): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid delivery job ID.", ) now = _now() result = await database.handoff_delivery_jobs.find_one_and_update( { "_id": ObjectId(job_id), "account_id": str(account.id), "status": {"$in": [STATUS_FAILED, STATUS_AMBIGUOUS]}, }, { "$set": { "status": STATUS_PENDING, "next_attempt_at": now, "updated_at": now, }, "$unset": { "error": "", "failed_at": "", "ambiguous_at": "", }, }, return_document=True, ) if result is None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="This delivery cannot be retried.", ) return {"id": str(result["_id"]), "status": result["status"]} @router.get( "/conversations/{handoff_id}", response_model=HandoffConversationRead, ) async def get_handoff_conversation( handoff_id: str, database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): if not ObjectId.is_valid(handoff_id): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid handoff ID.", ) account_id = str(account.id) handoff = await database.human_handoffs.find_one( {"_id": ObjectId(handoff_id), "account_id": account_id} ) if handoff is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Handoff conversation not found.", ) cursor = database.mailbox_entries.find( { "account_id": account_id, "thread_id": handoff["handoff_thread_id"], "message_scope": "HANDOFF", "deletion_state": {"$in": [None, ""]}, } ).sort([("event_at", 1), ("_id", 1)]) messages: list[HandoffMessageRead] = [] async for entry in cursor: message = await database.messages.find_one({"_id": entry["message_id"]}) if not message: continue delivery = await database.handoff_delivery_jobs.find_one( { "$or": [ {"source_message_id": message["_id"]}, {"target_message_id": message["_id"]}, ] }, sort=[("created_at", -1)], ) messages.append( HandoffMessageRead( id=str(message["_id"]), role="human" if message.get("direction") == "inbound" else "agent", subject=message.get("subject", ""), snippet=message.get("snippet", ""), body_text=message.get("body_text", ""), body_html=message.get("body_html"), new_reply_content=message.get("new_reply_content"), raw_body=message.get("body_text") if message.get("direction") == "inbound" else None, has_quoted_text=bool(message.get("has_quoted_text")), relay_status=message.get("relay_status"), delivery_status=(delivery or {}).get("status"), delivery_job_id=str(delivery["_id"]) if delivery else None, direction=message.get("direction", "outbound"), mailbox=entry["mailbox"], event_at=entry.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"], ) ) return HandoffConversationRead( id=str(handoff["_id"]), thread_id=str(handoff["handoff_thread_id"]), rule_name=handoff.get("rule_name", "Human handoff"), external_subject=handoff.get("external_subject", ""), external_sender=handoff.get("external_sender", ""), human_email=handoff.get("human_email", ""), handoff_reason="Matched handoff rule", status=handoff.get("status", "ACTIVE"), status_label=_handoff_status_label(handoff.get("status", "ACTIVE")), current_owner=handoff.get("current_owner", "HUMAN"), linked_external=HandoffLinkedExternal( thread_id=str(handoff["external_thread_id"]), subject=handoff.get("external_subject", ""), sender=handoff.get("external_sender", ""), ), messages=messages, ) @router.post("", response_model=HandoffRuleRead, status_code=status.HTTP_201_CREATED) async def create_handoff_rule( payload: HandoffRuleCreate, database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): now = _now() account_id = str(account.id) name = payload.name.strip() duplicate = await database.human_handoff_rules.find_one( {"account_id": account_id, "name_key": name.casefold()}, {"_id": 1}, ) if duplicate: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A handoff rule with this name already exists.", ) priority = payload.priority if priority is None: latest = await database.human_handoff_rules.find_one( {"account_id": account_id}, sort=[("priority", -1)], ) priority = int(latest.get("priority", -1)) + 1 if latest else 0 document = { "_id": ObjectId(), "account_id": account_id, "name": name, "name_key": name.casefold(), "priority": priority, "owner_email": str(payload.owner_email).lower(), "owner_name": payload.owner_name or _owner_name_from_email(str(payload.owner_email)), "labels": payload.labels, "email_types": payload.email_types, "instructions": payload.instructions.strip(), "allow_replies": payload.allow_replies, "reply_as": payload.reply_as, "active": payload.active, "created_at": now, "updated_at": now, } try: await database.human_handoff_rules.insert_one(document) except DuplicateKeyError: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A handoff rule with this name or priority already exists.", ) return _serialize(document) @router.put("/reorder", response_model=list[HandoffRuleRead]) async def reorder_handoff_rules( payload: HandoffRuleOrderUpdate, database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): if len(payload.rule_ids) != len(set(payload.rule_ids)): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Rule IDs must be unique.", ) if any(not ObjectId.is_valid(value) for value in payload.rule_ids): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.", ) account_id = str(account.id) object_ids = [ObjectId(value) for value in payload.rule_ids] count = await database.human_handoff_rules.count_documents( {"account_id": account_id, "_id": {"$in": object_ids}} ) if count != len(object_ids): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="One or more handoff rules were not found.", ) now = _now() # Shift priorities out of the unique range first so swaps cannot collide. for offset, rule_id in enumerate(object_ids): await database.human_handoff_rules.update_one( {"_id": rule_id, "account_id": account_id}, {"$set": {"priority": offset + 10_000, "updated_at": now}}, ) for priority, rule_id in enumerate(object_ids): await database.human_handoff_rules.update_one( {"_id": rule_id, "account_id": account_id}, {"$set": {"priority": priority, "updated_at": now}}, ) cursor = database.human_handoff_rules.find( {"account_id": account_id} ).sort([("priority", 1), ("created_at", 1), ("_id", 1)]) return [_serialize(document) async for document in cursor] @router.patch("/{rule_id}", response_model=HandoffRuleRead) async def update_handoff_rule( rule_id: str, payload: HandoffRuleUpdate, database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): if not ObjectId.is_valid(rule_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.") patch = payload.model_dump(exclude_unset=True) if "name" in patch: patch["name"] = patch["name"].strip() patch["name_key"] = patch["name"].casefold() duplicate = await database.human_handoff_rules.find_one( { "account_id": str(account.id), "name_key": patch["name_key"], "_id": {"$ne": ObjectId(rule_id)}, }, {"_id": 1}, ) if duplicate: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A handoff rule with this name already exists.", ) if "owner_email" in patch and patch["owner_email"]: patch["owner_email"] = str(patch["owner_email"]).lower() patch["updated_at"] = _now() try: result = await database.human_handoff_rules.find_one_and_update( {"_id": ObjectId(rule_id), "account_id": str(account.id)}, {"$set": patch}, return_document=True, ) except DuplicateKeyError: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A handoff rule with this name or priority already exists.", ) if result is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.") return _serialize(result) @router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_handoff_rule( rule_id: str, database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): if not ObjectId.is_valid(rule_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.") result = await database.human_handoff_rules.delete_one( {"_id": ObjectId(rule_id), "account_id": str(account.id)} ) if result.deleted_count == 0: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.") @router.post("/test", response_model=HandoffTestResult) async def test_handoff( payload: HandoffTestRequest, database=Depends(get_mongo_database), account: Account = Depends(get_current_account), ): labels = payload.labels email_types = payload.email_types instructions = payload.instructions owner_email = payload.owner_email if payload.rule_id: if not ObjectId.is_valid(payload.rule_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.") rule = await database.human_handoff_rules.find_one( {"_id": ObjectId(payload.rule_id), "account_id": str(account.id)} ) if rule is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.") labels = rule.get("labels", []) email_types = rule.get("email_types", []) instructions = rule.get("instructions", "") owner_email = rule.get("owner_email", owner_email) evaluation = evaluate_handoff( email=payload.email.model_dump(), labels=labels, email_types=email_types, instructions=instructions or "", ) return HandoffTestResult( owner_email=owner_email, **evaluation, result_path=( "Would hand this email to the Human Owner" if evaluation["would_handoff"] else "Would not match handoff rule" ), reason=evaluation["instruction_reason"], ) @router.get("/meta") async def handoff_meta(): return { "labels": HANDOFF_LABELS, "email_types": [ { "id": key, "title": meta["title"], } for key, meta in EMAIL_TYPE_META.items() ], }