human hand off

This commit is contained in:
debu-2 2026-10-06 07:12:55 +05:30
parent d0fee5c8f7
commit 7af776196a
34 changed files with 2941 additions and 195 deletions

View file

@ -23,6 +23,7 @@ from app.routers import (
)
from app.services.thread_service import create_thread, message_event_at
from app.services.domain_provisioning import ensure_platform_domain
from app.services.handoff_delivery import handoff_delivery_worker
from app.services.inbound_smtp import InboundSMTPServer
settings = get_settings()
@ -208,6 +209,11 @@ async def lifespan(_: FastAPI):
sparse=True,
unique=True,
)
await mongo_db.messages.create_index(
[("handoff_delivery_key", 1)],
sparse=True,
unique=True,
)
await mongo_db.mailbox_entries.create_index(
[("account_id", 1), ("thread_id", 1), ("event_at", -1), ("_id", -1)]
)
@ -217,18 +223,54 @@ async def lifespan(_: FastAPI):
await mongo_db.human_handoff_rules.create_index(
[("account_id", 1), ("updated_at", -1)]
)
account_ids = await mongo_db.human_handoff_rules.distinct("account_id")
for account_id in account_ids:
existing_rules = (
await mongo_db.human_handoff_rules.find({"account_id": account_id})
.sort([("priority", 1), ("created_at", 1), ("_id", 1)])
.to_list(length=1000)
)
for index, rule in enumerate(existing_rules):
if rule.get("priority") != index:
await mongo_db.human_handoff_rules.update_one(
{"_id": rule["_id"]},
{"$set": {"priority": index}},
)
await mongo_db.human_handoff_rules.create_index(
[("account_id", 1), ("priority", 1)],
unique=True,
)
await mongo_db.human_handoff_rules.create_index(
[("account_id", 1), ("name_key", 1)],
unique=True,
sparse=True,
)
await mongo_db.human_handoffs.create_index(
[("account_id", 1), ("updated_at", -1)]
)
await mongo_db.human_handoffs.create_index(
[("account_id", 1), ("external_thread_id", 1)]
[("account_id", 1), ("external_thread_id", 1)],
unique=True,
)
await mongo_db.human_handoffs.create_index(
[("account_id", 1), ("handoff_thread_id", 1)]
[("account_id", 1), ("handoff_thread_id", 1)],
unique=True,
)
await mongo_db.handoff_message_links.create_index(
[("handoff_id", 1), ("created_at", -1)]
)
await mongo_db.handoff_message_links.create_index(
[("handoff_id", 1), ("source_message_id", 1), ("relationship", 1)],
unique=True,
sparse=True,
)
await mongo_db.handoff_delivery_jobs.create_index(
[("idempotency_key", 1)],
unique=True,
)
await mongo_db.handoff_delivery_jobs.create_index(
[("status", 1), ("next_attempt_at", 1), ("created_at", 1)]
)
await mongo_db.mailbox_entries.create_index(
[("account_id", 1), ("message_scope", 1), ("mailbox", 1), ("event_at", -1)]
)
@ -239,9 +281,11 @@ async def lifespan(_: FastAPI):
await backfill_missing_thread_ids(mongo_db)
if settings.inbound_smtp_enabled:
await inbound_server.start()
await handoff_delivery_worker.start()
try:
yield
finally:
await handoff_delivery_worker.stop()
await inbound_server.stop()
await engine.dispose()
await mongo_client.close()

View file

@ -1,23 +1,36 @@
import re
from datetime import datetime, timezone
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, status
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)
@ -31,8 +44,10 @@ def _owner_name_from_email(email: str) -> str:
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", ""),
@ -51,10 +66,231 @@ async def list_handoff_rules(
):
cursor = database.human_handoff_rules.find(
{"account_id": str(account.id)}
).sort("updated_at", -1)
).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": "Active",
"WAITING_FOR_HUMAN": "Waiting for human",
"HUMAN_REPLIED": "Human replied",
"CLOSED": "Closed",
}.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", ""),
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,
@ -62,9 +298,30 @@ async def create_handoff_rule(
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": str(account.id),
"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,
@ -76,10 +333,60 @@ async def create_handoff_rule(
"created_at": now,
"updated_at": now,
}
await database.human_handoff_rules.insert_one(document)
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,
@ -90,14 +397,36 @@ async def update_handoff_rule(
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()
result = await database.human_handoff_rules.find_one_and_update(
{"_id": ObjectId(rule_id), "account_id": str(account.id)},
{"$set": patch},
return_document=True,
)
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)
@ -152,7 +481,7 @@ async def test_handoff(
owner_email=owner_email,
**evaluation,
result_path=(
"Would match handoff rule (configuration only)"
"Would hand this email to the Human Owner"
if evaluation["would_handoff"]
else "Would not match handoff rule"
),

View file

@ -1,5 +1,6 @@
import base64
import json
import logging
import re
from datetime import date, datetime, time, timedelta, timezone
from typing import Annotated
@ -35,10 +36,13 @@ from app.services.thread_service import (
message_event_at,
)
from app.services.external_reply_service import build_external_reply_plan, log_smtp_message_sending
from app.services.handoff_mirror_policy import GENERATED_BY_AGENT, MESSAGE_ORIGIN_AGENT
from app.services.human_handoff_service import maybe_trigger_handoff
from app.services.mailbox_scope import apply_external_scope
from app.services.thread_resolver import ThreadResolution
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/emails", tags=["emails"])
@ -191,6 +195,11 @@ async def create_email(
)
created_at = utc_now()
sender_name = " ".join(
part.strip()
for part in [account.first_name, account.last_name]
if part and part.strip()
)
subject = payload.subject
if parent_message:
reply_plan = build_external_reply_plan(
@ -213,10 +222,12 @@ async def create_email(
document = {
"direction": "outbound",
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"message_origin": MESSAGE_ORIGIN_AGENT,
"generated_by": GENERATED_BY_AGENT,
"account_id": str(account.id),
"sender": address(
sender,
" ".join(part for part in [account.first_name, account.last_name] if part),
sender_name,
),
"to": [address(value) for value in recipients],
"cc": [address(value) for value in cc],
@ -251,6 +262,7 @@ async def create_email(
)
internet_message_id = await deliver_email(
sender=sender,
sender_name=sender_name,
recipients=recipients,
cc=cc,
bcc=bcc,
@ -324,6 +336,22 @@ async def create_email(
"deletion_state": None,
}
)
if document["status"] == "accepted":
try:
await maybe_trigger_handoff(
database,
account_id=str(account.id),
agent_email=sender,
external_message=document,
external_thread_id=thread_id,
)
except Exception:
logger.exception(
"handoff_trigger_failed account_id=%s thread_id=%s message_id=%s",
account.id,
thread_id,
document["_id"],
)
if document["status"] == "failed":
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,

View file

@ -232,8 +232,10 @@ class HandoffEmailSample(BaseModel):
class HandoffRuleCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
owner_email: EmailStr
owner_name: str | None = None
priority: int | None = Field(default=None, ge=0, le=10000)
labels: list[str] = Field(default_factory=list)
email_types: list[str] = Field(default_factory=list)
instructions: str = ""
@ -243,8 +245,10 @@ class HandoffRuleCreate(BaseModel):
class HandoffRuleUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
owner_email: EmailStr | None = None
owner_name: str | None = None
priority: int | None = Field(default=None, ge=0, le=10000)
labels: list[str] | None = None
email_types: list[str] | None = None
instructions: str | None = None
@ -255,8 +259,10 @@ class HandoffRuleUpdate(BaseModel):
class HandoffRuleRead(BaseModel):
id: str
name: str
owner_email: EmailStr
owner_name: str
priority: int
labels: list[str]
email_types: list[str]
instructions: str
@ -267,6 +273,10 @@ class HandoffRuleRead(BaseModel):
updated_at: datetime
class HandoffRuleOrderUpdate(BaseModel):
rule_ids: list[str] = Field(min_length=1, max_length=100)
class HandoffTestRequest(BaseModel):
rule_id: str | None = None
owner_email: EmailStr | None = None
@ -297,6 +307,9 @@ class HandoffLinkedExternal(BaseModel):
class HandoffConversationSummary(BaseModel):
id: str
thread_id: str
mailbox: Literal["handoff_inbox", "handoff_outbox"]
rule_name: str
external_subject: str
external_sender: str
human_email: str
@ -314,15 +327,22 @@ class HandoffMessageRead(BaseModel):
subject: str
snippet: str
body_text: str
body_html: str | None = None
new_reply_content: str | None = None
raw_body: str | None = None
has_quoted_text: bool = False
relay_status: str | None = None
delivery_status: str | None = None
delivery_job_id: str | None = None
direction: Literal["inbound", "outbound"]
mailbox: Literal["handoff_inbox", "handoff_outbox"]
event_at: datetime
class HandoffConversationRead(BaseModel):
id: str
thread_id: str
rule_name: str
external_subject: str
external_sender: str
human_email: str

View file

@ -17,6 +17,7 @@ from app.services.message_ids import (
message_id_fields,
)
from app.services.mime_parser import parse_mime
from app.services.mime_body_extractor import ExtractedMimePart
from app.services.reply_extractor import build_reply_display
from app.services.raw_mime_storage import get_raw_mime_storage
from app.services.thread_service import threading_fields_from_parent
@ -351,6 +352,9 @@ async def send_external_reply(
generated_by: str | None = None,
generated_from_handoff_message_id: ObjectId | None = None,
message_origin: str | None = None,
message_id: str | None = None,
inline_parts: list[ExtractedMimePart] | None = None,
file_attachments: list[ExtractedMimePart] | None = None,
) -> dict:
if generated_from_handoff_message_id:
existing = await database.messages.find_one(
@ -386,6 +390,7 @@ async def send_external_reply(
internet_message_id = await deliver_email(
sender=sender,
sender_name=sender_name,
recipients=plan.recipients,
cc=[],
bcc=[],
@ -395,6 +400,9 @@ async def send_external_reply(
domain=domain,
in_reply_to=plan.threading.in_reply_to,
references=plan.threading.references,
message_id=message_id,
inline_parts=inline_parts,
file_attachments=file_attachments,
)
normalized_id = canonical_message_id(internet_message_id)
id_fields = message_id_fields(normalized_id)
@ -428,7 +436,16 @@ async def send_external_reply(
"has_quoted_text": reply_display["has_quoted_text"],
"body_text": plan.body_text,
"body_html": plan.body_html,
"attachments": [],
"attachments": [
{
"filename": part.filename,
"content_type": part.content_type,
"size": len(part.payload),
"content_id": part.content_id,
"disposition": part.disposition,
}
for part in [*(inline_parts or []), *(file_attachments or [])]
],
"created_at": now,
"sent_at": now,
"status": "accepted",
@ -469,7 +486,7 @@ async def send_external_reply(
"recipients": document["to"],
"subject": plan.subject,
"snippet": document["snippet"],
"attachments": [],
"attachments": document["attachments"],
"status": "accepted",
"is_read": True,
"is_starred": False,

View file

@ -0,0 +1,215 @@
import asyncio
import email.utils
import logging
from datetime import datetime, timezone
from bson import ObjectId
from app.database import mongo_db
logger = logging.getLogger(__name__)
JOB_MIRROR = "MIRROR_TO_HUMAN"
JOB_RELAY = "RELAY_TO_EXTERNAL"
STATUS_PENDING = "PENDING"
STATUS_SENDING = "SENDING"
STATUS_SENT = "SENT"
STATUS_FAILED = "FAILED"
STATUS_AMBIGUOUS = "AMBIGUOUS"
class HandoffPreflightError(RuntimeError):
"""A deterministic error that happened before SMTP submission."""
def _now() -> datetime:
return datetime.now(timezone.utc)
async def enqueue_handoff_delivery(
database,
*,
kind: str,
account_id: str,
handoff_id: ObjectId,
source_message_id: ObjectId,
agent_email: str,
) -> dict:
idempotency_key = f"{kind}:{account_id}:{handoff_id}:{source_message_id}"
now = _now()
document = {
"idempotency_key": idempotency_key,
"kind": kind,
"account_id": account_id,
"handoff_id": handoff_id,
"source_message_id": source_message_id,
"agent_email": agent_email.lower(),
"generated_message_id": email.utils.make_msgid(
domain=agent_email.rsplit("@", 1)[-1].lower()
),
"status": STATUS_PENDING,
"attempt_count": 0,
"next_attempt_at": now,
"created_at": now,
"updated_at": now,
}
await database.handoff_delivery_jobs.update_one(
{"idempotency_key": idempotency_key},
{"$setOnInsert": document},
upsert=True,
)
return await database.handoff_delivery_jobs.find_one(
{"idempotency_key": idempotency_key}
)
async def process_handoff_delivery_once(database=mongo_db) -> bool:
now = _now()
job = await database.handoff_delivery_jobs.find_one_and_update(
{
"status": STATUS_PENDING,
"next_attempt_at": {"$lte": now},
},
{
"$set": {
"status": STATUS_SENDING,
"started_at": now,
"updated_at": now,
},
"$inc": {"attempt_count": 1},
},
sort=[("created_at", 1), ("_id", 1)],
return_document=True,
)
if not job:
return False
try:
from app.services.human_handoff_service import deliver_handoff_job
target_message_id = await deliver_handoff_job(database, job)
except HandoffPreflightError as error:
logger.error(
"handoff_delivery_preflight_failed job=%s kind=%s error=%s",
job["_id"],
job["kind"],
error,
)
await database.handoff_delivery_jobs.update_one(
{"_id": job["_id"], "status": STATUS_SENDING},
{
"$set": {
"status": STATUS_FAILED,
"error": str(error),
"failed_at": _now(),
"updated_at": _now(),
}
},
)
if job["kind"] == JOB_RELAY:
await database.messages.update_one(
{"_id": job["source_message_id"]},
{
"$set": {
"relay_status": STATUS_FAILED,
"relay_error": str(error),
"relay_failed_at": _now(),
}
},
)
except Exception as error:
# SMTP exceptions can be ambiguous: the remote MTA may have accepted the
# message before the acknowledgement was lost. Never auto-retry these.
logger.exception(
"handoff_delivery_ambiguous job=%s kind=%s",
job["_id"],
job["kind"],
)
await database.handoff_delivery_jobs.update_one(
{"_id": job["_id"], "status": STATUS_SENDING},
{
"$set": {
"status": STATUS_AMBIGUOUS,
"error": str(error),
"ambiguous_at": _now(),
"updated_at": _now(),
}
},
)
if job["kind"] == JOB_RELAY:
await database.messages.update_one(
{"_id": job["source_message_id"]},
{
"$set": {
"relay_status": STATUS_AMBIGUOUS,
"relay_error": str(error),
"relay_failed_at": _now(),
}
},
)
else:
await database.handoff_delivery_jobs.update_one(
{"_id": job["_id"], "status": STATUS_SENDING},
{
"$set": {
"status": STATUS_SENT,
"target_message_id": target_message_id,
"sent_at": _now(),
"updated_at": _now(),
}
},
)
return True
class HandoffDeliveryWorker:
def __init__(self, poll_seconds: float = 1.0):
self.poll_seconds = poll_seconds
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
async def start(self) -> None:
if self._task and not self._task.done():
return
# A process that died during SMTP submission cannot know whether the
# remote MTA accepted it. Quarantine instead of risking a duplicate.
await mongo_db.handoff_delivery_jobs.update_many(
{"status": STATUS_SENDING},
{
"$set": {
"status": STATUS_AMBIGUOUS,
"error": "Worker restarted during SMTP submission.",
"ambiguous_at": _now(),
"updated_at": _now(),
}
},
)
self._stop.clear()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
self._stop.set()
if self._task:
await self._task
self._task = None
async def _run(self) -> None:
while not self._stop.is_set():
processed = False
try:
processed = await process_handoff_delivery_once()
except Exception:
logger.exception("handoff_delivery_worker_iteration_failed")
if processed:
continue
try:
await asyncio.wait_for(
self._stop.wait(),
timeout=self.poll_seconds,
)
except TimeoutError:
pass
handoff_delivery_worker = HandoffDeliveryWorker()

View file

@ -1,13 +1,5 @@
from dataclasses import dataclass, field
from app.services.handoff_email_templates import (
build_mirror_handoff_html,
build_mirror_handoff_text,
build_mirror_update_html,
build_mirror_update_text,
format_sender_line,
)
from app.services.html_sanitizer import plain_text_to_html, sanitize_email_html
from app.services.mime_body_extractor import ExtractedMimePart, MirroredMimeContent, extract_mirror_content
from app.services.raw_mime_storage import get_raw_mime_storage
@ -21,11 +13,6 @@ class HandoffMirrorPayload:
async def load_mirror_payload(external_message: dict) -> HandoffMirrorPayload:
sender = external_message.get("sender") or {}
sender_line = format_sender_line(sender)
external_subject = external_message.get("subject", "")
agent_email = external_message.get("_agent_email", "")
body_text = (external_message.get("body_text") or external_message.get("snippet") or "").strip()
body_html = external_message.get("body_html")
inline_parts: list[ExtractedMimePart] = []
@ -44,29 +31,12 @@ async def load_mirror_payload(external_message: dict) -> HandoffMirrorPayload:
except Exception:
pass
if body_html:
original_html = sanitize_email_html(body_html)
elif body_text:
original_html = plain_text_to_html(body_text)
else:
original_html = plain_text_to_html("(No message body)")
if not body_text:
body_text = external_message.get("snippet") or "(No message body)"
return HandoffMirrorPayload(
body_text=build_mirror_handoff_text(
sender_line=sender_line,
external_subject=external_subject,
original_body_text=body_text,
agent_email=agent_email,
),
body_html=build_mirror_handoff_html(
sender_line=sender_line,
external_subject=external_subject,
original_html=original_html,
agent_email=agent_email,
),
body_text=body_text,
body_html=body_html,
inline_parts=inline_parts,
file_attachments=file_attachments,
)
@ -77,32 +47,4 @@ async def load_mirror_update_payload(
*,
agent_email: str,
) -> HandoffMirrorPayload:
external_message = {**external_message, "_agent_email": agent_email}
payload = await load_mirror_payload(external_message)
sender = external_message.get("sender") or {}
sender_line = format_sender_line(sender)
external_subject = external_message.get("subject", "")
body_text = (external_message.get("body_text") or external_message.get("snippet") or "").strip()
body_html = external_message.get("body_html")
if body_html:
original_html = sanitize_email_html(body_html)
else:
original_html = plain_text_to_html(body_text or "(No message body)")
return HandoffMirrorPayload(
body_text=build_mirror_update_text(
sender_line=sender_line,
external_subject=external_subject,
original_body_text=body_text,
agent_email=agent_email,
),
body_html=build_mirror_update_html(
sender_line=sender_line,
external_subject=external_subject,
original_html=original_html,
agent_email=agent_email,
),
inline_parts=payload.inline_parts,
file_attachments=payload.file_attachments,
)
return await load_mirror_payload(external_message)

View file

@ -19,6 +19,7 @@ GENERATED_BY_RELAY_ACK = "RELAY_ACK"
RELAY_STATUS_SENT = "SENT"
RELAY_STATUS_FAILED = "FAILED"
RELAY_STATUS_PENDING = "PENDING"
RELAY_STATUS_BLOCKED = "BLOCKED"
def should_mirror_to_human(message: dict) -> bool:
@ -27,7 +28,7 @@ def should_mirror_to_human(message: dict) -> bool:
if scope != MESSAGE_SCOPE_EXTERNAL:
return False
if message.get("direction") != "inbound":
if message.get("direction") not in ("inbound", "outbound"):
return False
generated_by = message.get("generated_by")
@ -49,6 +50,11 @@ def should_mirror_to_human(message: dict) -> bool:
if message.get("generated_from_handoff_message_id"):
return False
if message.get("direction") == "outbound":
return (
generated_by == GENERATED_BY_AGENT
or origin == MESSAGE_ORIGIN_AGENT
)
return True

View file

@ -1,8 +1,918 @@
"""Human Handoff runtime is disabled — configuration-only via agent_handoff router.
import logging
import re
from datetime import datetime, timezone
from uuid import UUID
This module retains scope constants used by normal mail filtering. All email
sending, receiving, mirroring, forwarding, and relay behavior has been removed.
"""
from bson import ObjectId
from pymongo.errors import DuplicateKeyError
from app.database import SessionFactory
from app.models import Account
from app.services.domain_provisioning import get_domain_by_name
from app.services.external_reply_service import (
ExternalReplyThreadingError,
build_external_reply_plan,
resolve_external_reply_parent,
resolve_internet_message_id,
send_external_reply,
)
from app.services.handoff_delivery import (
HandoffPreflightError,
JOB_MIRROR,
JOB_RELAY,
enqueue_handoff_delivery,
)
from app.services.handoff_evaluator import evaluate_handoff
from app.services.handoff_mirror import load_mirror_payload
from app.services.handoff_mirror_policy import (
GENERATED_BY_AGENT,
GENERATED_BY_HANDOFF_BRIDGE,
GENERATED_BY_HUMAN,
MESSAGE_ORIGIN_AGENT,
MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
MESSAGE_ORIGIN_HANDOFF_MIRROR,
MESSAGE_ORIGIN_HUMAN_OWNER,
RELAY_STATUS_BLOCKED,
RELAY_STATUS_FAILED,
RELAY_STATUS_PENDING,
RELAY_STATUS_SENT,
should_mirror_to_human,
)
from app.services.mail_sender import deliver_email
from app.services.message_ids import (
canonical_message_id,
folded_message_id,
message_id_fields,
parse_message_id_list,
)
from app.services.mime_body_extractor import extract_new_reply_mime_content
from app.services.raw_mime_storage import get_raw_mime_storage
from app.services.reply_extractor import (
extract_new_reply_content,
validate_external_reply_body,
)
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_HUMAN_HANDOFF = "HUMAN_HANDOFF"
HANDOFF_STATUS_ACTIVE = "ACTIVE"
HANDOFF_STATUS_WAITING = "WAITING_FOR_HUMAN"
HANDOFF_STATUS_HUMAN_REPLIED = "HUMAN_REPLIED"
OWNER_AGENT = "AGENT"
OWNER_HUMAN = "HUMAN"
LINK_EXTERNAL_MIRRORED = "EXTERNAL_MIRRORED_TO_HUMAN"
LINK_HUMAN_RELAYED = "HUMAN_RELAYED_TO_EXTERNAL"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _snippet(value: str, limit: int = 240) -> str:
return re.sub(r"\s+", " ", value or "").strip()[:limit]
def _safe_subject_part(value: str, fallback: str) -> str:
cleaned = re.sub(r"[\r\n]+", " ", value or "").strip()
return cleaned or fallback
def build_handoff_subject(
*,
rule_name: str,
external_sender: str,
external_subject: str,
) -> str:
subject = " · ".join(
[
_safe_subject_part(rule_name, "Human"),
"handoff",
_safe_subject_part(external_sender, "unknown sender"),
_safe_subject_part(external_subject, "(No subject)"),
]
)
return subject[:998].rstrip()
def redact_human_address(
value: str | None,
*,
human_email: str,
agent_email: str,
) -> str | None:
if value is None or not human_email:
return value
return re.sub(
re.escape(human_email),
agent_email,
value,
flags=re.IGNORECASE,
)
def select_matching_handoff_rule(
rules: list[dict],
*,
email: dict,
) -> dict | None:
ordered = sorted(
rules,
key=lambda rule: (
int(rule.get("priority", 0)),
(
rule["created_at"].timestamp()
if isinstance(rule.get("created_at"), datetime)
else 0
),
str(rule.get("_id", "")),
),
)
return next(
(
rule
for rule in ordered
if evaluate_handoff(
email=email,
labels=rule.get("labels", []),
email_types=rule.get("email_types", []),
instructions=rule.get("instructions", ""),
)["would_handoff"]
),
None,
)
async def _account_and_domain(account_id: str, agent_email: str):
async with SessionFactory() as session:
account = await session.get(Account, UUID(account_id))
domain = await get_domain_by_name(session, agent_email.rsplit("@", 1)[-1])
if account is None:
raise HandoffPreflightError("Handoff account no longer exists.")
if domain is None:
raise HandoffPreflightError("Agent mail domain is not configured.")
if domain.domain_type == "company" and domain.status != "active":
raise HandoffPreflightError("Agent mail domain is not active.")
sender_name = " ".join(
part.strip()
for part in [account.first_name, account.last_name]
if part and part.strip()
)
return account, domain, sender_name
async def create_handoff_thread(
database,
*,
account_id: str,
subject: str,
) -> ObjectId:
now = _now()
result = await database.email_threads.insert_one(
{
"account_id": account_id,
"thread_type": THREAD_TYPE_HUMAN_HANDOFF,
"normalized_subject": subject.casefold(),
"created_at": now,
"updated_at": now,
"last_message_at": now,
"latest_message_id": None,
"message_count": 0,
}
)
return result.inserted_id
async def _latest_thread_message(
database,
*,
account_id: str,
thread_id: ObjectId,
scope: str,
) -> dict | None:
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": thread_id,
"message_scope": scope,
"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 find_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": {"$ne": "CLOSED"},
}
)
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.strip().lower()
if not sender or not agent_email:
return None
search_ids = parse_message_id_list(
" ".join([*([in_reply_to] if in_reply_to else []), *(references or [])])
)
for internet_id in reversed(search_ids):
folded = folded_message_id(internet_id)
if not folded:
continue
parent = await database.messages.find_one(
{
"message_scope": MESSAGE_SCOPE_HANDOFF,
"$or": [
{"message_id_folded": folded},
{"message_id": canonical_message_id(internet_id)},
],
},
{"_id": 1},
)
if parent is None:
continue
entry = await database.mailbox_entries.find_one(
{
"account_id": account_id,
"message_id": parent["_id"],
"message_scope": MESSAGE_SCOPE_HANDOFF,
},
{"thread_id": 1},
)
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,
"agent_email": agent_email.lower(),
"status": {"$ne": "CLOSED"},
}
)
if handoff:
return handoff
return None
async def _record_link(
database,
*,
handoff_id: ObjectId,
source_message_id: ObjectId,
external_message_id: ObjectId | None,
handoff_message_id: ObjectId | None,
relationship: str,
) -> None:
await database.handoff_message_links.update_one(
{
"handoff_id": handoff_id,
"source_message_id": source_message_id,
"relationship": relationship,
},
{
"$setOnInsert": {
"handoff_id": handoff_id,
"source_message_id": source_message_id,
"external_message_id": external_message_id,
"handoff_message_id": handoff_message_id,
"relationship": relationship,
"created_at": _now(),
}
},
upsert=True,
)
def _attachment_metadata(parts) -> list[dict]:
return [
{
"filename": part.filename,
"content_type": part.content_type,
"size": len(part.payload),
"content_id": part.content_id,
"disposition": part.disposition,
}
for part in parts
]
async def store_handoff_message(
database,
*,
account_id: str,
thread_id: ObjectId,
direction: str,
sender: dict,
recipients: list[dict],
subject: str,
body_text: str,
body_html: str | None,
internet_message_id: str | None,
in_reply_to: str | None,
references: list[str],
handoff_id: ObjectId,
event_at: datetime | None = None,
attachments: list[dict] | None = None,
raw_mime: dict | None = None,
handoff_delivery_key: str | None = None,
new_reply_content: str | None = None,
new_reply_html: str | None = None,
quoted_content: str | None = None,
message_origin: str | None = None,
generated_by: str | None = None,
mirrored_from_external_message_id: ObjectId | None = None,
relay_status: str | None = None,
) -> dict:
if handoff_delivery_key:
existing = await database.messages.find_one(
{"handoff_delivery_key": handoff_delivery_key}
)
if existing:
return existing
now = event_at or _now()
id_fields = message_id_fields(canonical_message_id(internet_message_id))
document = {
"account_id": account_id,
"direction": direction,
"message_scope": MESSAGE_SCOPE_HANDOFF,
"handoff_id": handoff_id,
"sender": sender,
"to": recipients,
"subject": subject,
"snippet": _snippet(new_reply_content or body_text),
"body_text": body_text,
"body_html": body_html,
"new_reply_content": new_reply_content,
"new_reply_html": new_reply_html,
"quoted_content": quoted_content or "",
"has_quoted_text": bool(quoted_content),
"attachments": attachments or [],
"created_at": now,
"received_at": now if direction == "inbound" else None,
"sent_at": now if direction == "outbound" else None,
"status": "received" if direction == "inbound" else "accepted",
**id_fields,
"in_reply_to": canonical_message_id(in_reply_to),
"references": references or [],
"message_origin": message_origin,
"generated_by": generated_by,
"relay_status": relay_status,
}
if raw_mime:
document["raw_mime"] = raw_mime
if handoff_delivery_key:
document["handoff_delivery_key"] = handoff_delivery_key
if mirrored_from_external_message_id:
document["mirrored_from_external_message_id"] = mirrored_from_external_message_id
try:
result = await database.messages.insert_one(document)
document["_id"] = result.inserted_id
except DuplicateKeyError:
query = (
{"handoff_delivery_key": handoff_delivery_key}
if handoff_delivery_key
else {"mirrored_from_external_message_id": mirrored_from_external_message_id}
)
existing = await database.messages.find_one(query)
if existing:
return existing
raise
await attach_message_to_thread(
database,
account_id=account_id,
thread_id=thread_id,
message_id=document["_id"],
subject=subject,
event_at=now,
)
mailbox = "handoff_inbox" if direction == "inbound" else "handoff_outbox"
await database.mailbox_entries.update_one(
{
"account_id": account_id,
"message_id": document["_id"],
"mailbox": mailbox,
},
{
"$setOnInsert": {
"account_id": account_id,
"message_id": document["_id"],
"thread_id": thread_id,
"mailbox": mailbox,
"message_scope": MESSAGE_SCOPE_HANDOFF,
"direction": direction,
"sender": sender,
"recipients": recipients,
"subject": subject,
"snippet": document["snippet"],
"attachments": attachments or [],
"status": document["status"],
"is_read": direction == "outbound",
"is_starred": False,
"event_at": now,
"created_at": now,
"trashed_at": None,
"deletion_state": None,
}
},
upsert=True,
)
return document
async def _create_handoff_mapping(
database,
*,
account_id: str,
agent_email: str,
external_message: dict,
external_thread_id: ObjectId,
rule: dict,
) -> dict:
sender = external_message.get("sender") or {}
sender_text = sender.get("address") or sender.get("name") or "unknown sender"
private_subject = build_handoff_subject(
rule_name=rule.get("name") or rule.get("owner_name") or "Human",
external_sender=sender_text,
external_subject=external_message.get("subject", ""),
)
thread_id = await create_handoff_thread(
database,
account_id=account_id,
subject=private_subject,
)
now = _now()
document = {
"account_id": account_id,
"external_thread_id": external_thread_id,
"handoff_thread_id": thread_id,
"rule_id": rule["_id"],
"rule_name": rule.get("name") or rule.get("owner_name") or "Human",
"rule_priority": rule.get("priority", 0),
"human_email": rule["owner_email"].lower(),
"human_name": rule.get("owner_name") or rule["owner_email"].split("@", 1)[0],
"agent_email": agent_email.lower(),
"external_sender": sender.get("address", "").lower(),
"external_subject": external_message.get("subject", ""),
"private_subject": private_subject,
"allow_replies": rule.get("allow_replies", True),
"status": HANDOFF_STATUS_WAITING,
"current_owner": OWNER_HUMAN,
"trigger_external_message_id": external_message["_id"],
"trigger_internet_message_id": external_message.get("internet_message_id")
or external_message.get("message_id"),
"created_at": now,
"updated_at": now,
}
try:
result = await database.human_handoffs.insert_one(document)
document["_id"] = result.inserted_id
return document
except DuplicateKeyError:
await database.email_threads.delete_one({"_id": thread_id, "message_count": 0})
existing = await find_handoff_for_external_thread(
database,
account_id=account_id,
external_thread_id=external_thread_id,
)
if existing:
return existing
raise
async def maybe_trigger_handoff(
database,
*,
account_id: str,
agent_email: str,
external_message: dict,
external_thread_id: ObjectId,
) -> dict | None:
if not should_mirror_to_human(external_message):
return None
sender_address = (
(external_message.get("sender") or {}).get("address") or ""
).lower()
if sender_address == agent_email.lower():
return None
handoff = await find_handoff_for_external_thread(
database,
account_id=account_id,
external_thread_id=external_thread_id,
)
if handoff is None:
if external_message.get("direction") != "inbound":
return None
owner_sender = await database.human_handoff_rules.find_one(
{
"account_id": account_id,
"owner_email": sender_address,
"active": True,
},
{"_id": 1},
)
if owner_sender:
return None
rules = await database.human_handoff_rules.find(
{"account_id": account_id, "active": True}
).sort([("priority", 1), ("created_at", 1), ("_id", 1)]).to_list(length=100)
email_payload = {
"subject": external_message.get("subject", ""),
"snippet": external_message.get("snippet", ""),
"body_text": external_message.get("body_text", ""),
"is_starred": False,
}
rule = select_matching_handoff_rule(
rules,
email=email_payload,
)
if rule is None:
return None
handoff = await _create_handoff_mapping(
database,
account_id=account_id,
agent_email=agent_email,
external_message=external_message,
external_thread_id=external_thread_id,
rule=rule,
)
await enqueue_handoff_delivery(
database,
kind=JOB_MIRROR,
account_id=account_id,
handoff_id=handoff["_id"],
source_message_id=external_message["_id"],
agent_email=agent_email,
)
return handoff
async def process_human_reply(
database,
*,
account_id: str,
agent_email: str,
handoff: dict,
parsed: dict,
body_text: str,
body_html: str | None,
raw_mime: dict,
delivery_key: str,
) -> dict:
extracted = extract_new_reply_content(
body_text=body_text,
body_html=body_html,
)
rich = None
try:
raw = await get_raw_mime_storage().get(raw_mime)
rich = extract_new_reply_mime_content(raw)
except Exception:
logger.exception("handoff_reply_rich_extract_failed delivery=%s", delivery_key)
human_email = (parsed.get("sender") or {}).get("address", "").lower()
new_text = (
rich.body_text if rich and rich.body_text else extracted["new_reply_content"]
).strip()
new_html = rich.body_html if rich else None
if human_email:
new_text = redact_human_address(
new_text,
human_email=human_email,
agent_email=agent_email,
)
new_html = redact_human_address(
new_html,
human_email=human_email,
agent_email=agent_email,
)
auto_submitted = (
(parsed.get("raw_headers") or {}).get("auto_submitted") or ""
).strip().lower()
is_automatic = bool(auto_submitted and auto_submitted != "no")
private_markers_ok = validate_external_reply_body(new_text)
relay_status = (
RELAY_STATUS_PENDING
if (
new_text
and private_markers_ok
and not is_automatic
and handoff.get("allow_replies", True)
)
else RELAY_STATUS_BLOCKED
)
human_message = await store_handoff_message(
database,
account_id=account_id,
thread_id=handoff["handoff_thread_id"],
direction="inbound",
sender=parsed.get("sender") or {"name": "", "address": human_email},
recipients=parsed.get("to", []),
subject=parsed.get("subject", ""),
body_text=body_text,
body_html=body_html,
internet_message_id=parsed.get("message_id"),
in_reply_to=parsed.get("in_reply_to"),
references=parsed.get("references", []),
handoff_id=handoff["_id"],
event_at=parsed.get("sent_at") or _now(),
attachments=parsed.get("attachments", []),
raw_mime=raw_mime,
handoff_delivery_key=f"handoff:{account_id}:{handoff['_id']}:{delivery_key}",
new_reply_content=new_text,
new_reply_html=new_html,
quoted_content=extracted.get("quoted_content", ""),
message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
generated_by=GENERATED_BY_HUMAN,
relay_status=relay_status,
)
if relay_status == RELAY_STATUS_PENDING:
await enqueue_handoff_delivery(
database,
kind=JOB_RELAY,
account_id=account_id,
handoff_id=handoff["_id"],
source_message_id=human_message["_id"],
agent_email=agent_email,
)
else:
await database.messages.update_one(
{"_id": human_message["_id"]},
{
"$set": {
"relay_error": (
"Human replies are disabled for this handoff."
if not handoff.get("allow_replies", True)
else (
"Automatic replies are not relayed through Human Handoff."
if is_automatic
else "Reply content could not be safely separated from private history."
)
)
}
},
)
await database.human_handoffs.update_one(
{"_id": handoff["_id"]},
{
"$set": {
"status": HANDOFF_STATUS_HUMAN_REPLIED,
"current_owner": OWNER_AGENT,
"last_human_message_at": _now(),
"updated_at": _now(),
}
},
)
return human_message
async def _deliver_mirror(database, job: dict, handoff: dict, source: dict) -> ObjectId:
existing = await database.messages.find_one(
{
"message_scope": MESSAGE_SCOPE_HANDOFF,
"mirrored_from_external_message_id": source["_id"],
}
)
if existing:
return existing["_id"]
_, domain, sender_name = await _account_and_domain(
job["account_id"], handoff["agent_email"]
)
payload = await load_mirror_payload(source)
parent = await _latest_thread_message(
database,
account_id=job["account_id"],
thread_id=handoff["handoff_thread_id"],
scope=MESSAGE_SCOPE_HANDOFF,
)
threading = threading_fields_from_parent(parent) if parent else {}
subject = handoff["private_subject"]
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_name=sender_name,
recipients=[handoff["human_email"]],
cc=[],
bcc=[],
subject=subject,
body_text=payload.body_text,
body_html=payload.body_html,
domain=domain,
in_reply_to=threading.get("in_reply_to"),
references=threading.get("references"),
message_id=job["generated_message_id"],
inline_parts=payload.inline_parts,
file_attachments=payload.file_attachments,
)
attachments = _attachment_metadata(
[*payload.inline_parts, *payload.file_attachments]
)
mirror = await store_handoff_message(
database,
account_id=job["account_id"],
thread_id=handoff["handoff_thread_id"],
direction="outbound",
sender={"name": sender_name, "address": handoff["agent_email"]},
recipients=[
{
"name": handoff.get("human_name", ""),
"address": handoff["human_email"],
}
],
subject=subject,
body_text=payload.body_text,
body_html=payload.body_html,
internet_message_id=internet_message_id,
in_reply_to=threading.get("in_reply_to"),
references=threading.get("references", []),
handoff_id=handoff["_id"],
attachments=attachments,
message_origin=MESSAGE_ORIGIN_HANDOFF_MIRROR,
generated_by=GENERATED_BY_HANDOFF_BRIDGE,
mirrored_from_external_message_id=source["_id"],
)
await _record_link(
database,
handoff_id=handoff["_id"],
source_message_id=source["_id"],
external_message_id=source["_id"],
handoff_message_id=mirror["_id"],
relationship=LINK_EXTERNAL_MIRRORED,
)
await database.human_handoffs.update_one(
{"_id": handoff["_id"]},
{
"$set": {
"status": HANDOFF_STATUS_WAITING,
"current_owner": OWNER_HUMAN,
"last_mirrored_at": _now(),
"updated_at": _now(),
}
},
)
return mirror["_id"]
async def _deliver_relay(database, job: dict, handoff: dict, source: dict) -> ObjectId:
existing = await database.messages.find_one(
{
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"generated_from_handoff_message_id": source["_id"],
}
)
if existing:
return existing["_id"]
new_text = (source.get("new_reply_content") or "").strip()
if not new_text or not validate_external_reply_body(new_text):
raise HandoffPreflightError(
"Reply content failed the private-content leakage guard."
)
external_parent = await resolve_external_reply_parent(
database,
account_id=job["account_id"],
external_thread_id=handoff["external_thread_id"],
customer_address=handoff["external_sender"],
handoff=handoff,
)
if external_parent is None:
raise HandoffPreflightError("No valid external reply parent was found.")
parent_internet_id = await resolve_internet_message_id(database, external_parent)
if not parent_internet_id:
raise HandoffPreflightError(
"The external reply parent has no RFC Message-ID."
)
recipient = handoff.get("external_sender") or (
external_parent.get("sender") or {}
).get("address")
if not recipient:
raise HandoffPreflightError("The external sender address is missing.")
_, domain, sender_name = await _account_and_domain(
job["account_id"], handoff["agent_email"]
)
inline_parts = []
file_attachments = []
body_html = source.get("new_reply_html")
if source.get("raw_mime"):
raw = await get_raw_mime_storage().get(source["raw_mime"])
rich = extract_new_reply_mime_content(raw)
inline_parts = rich.inline_parts
file_attachments = rich.file_attachments
plan = build_external_reply_plan(
parent_external_message=external_parent,
body_text=new_text,
body_html=body_html,
subject_override=None,
recipients=[recipient],
parent_internet_id_override=parent_internet_id,
)
try:
external = await send_external_reply(
database,
account_id=job["account_id"],
sender=handoff["agent_email"],
sender_name=sender_name,
external_thread_id=handoff["external_thread_id"],
plan=plan,
domain=domain,
resolution_method="human_handoff_bridge",
generated_by=GENERATED_BY_HANDOFF_BRIDGE,
generated_from_handoff_message_id=source["_id"],
message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
message_id=job["generated_message_id"],
inline_parts=inline_parts,
file_attachments=file_attachments,
)
except ExternalReplyThreadingError as error:
raise HandoffPreflightError(str(error)) from error
await database.messages.update_one(
{"_id": source["_id"]},
{
"$set": {
"relay_status": RELAY_STATUS_SENT,
"generated_external_message_id": external["_id"],
"relay_delivered_at": _now(),
}
},
)
await _record_link(
database,
handoff_id=handoff["_id"],
source_message_id=source["_id"],
external_message_id=external["_id"],
handoff_message_id=source["_id"],
relationship=LINK_HUMAN_RELAYED,
)
await database.human_handoffs.update_one(
{"_id": handoff["_id"]},
{
"$set": {
"status": HANDOFF_STATUS_ACTIVE,
"current_owner": OWNER_AGENT,
"updated_at": _now(),
}
},
)
return external["_id"]
async def deliver_handoff_job(database, job: dict) -> ObjectId:
handoff = await database.human_handoffs.find_one(
{"_id": job["handoff_id"], "account_id": job["account_id"]}
)
if handoff is None:
raise HandoffPreflightError("Linked handoff no longer exists.")
source = await database.messages.find_one({"_id": job["source_message_id"]})
if source is None:
raise HandoffPreflightError("Source message no longer exists.")
if job["kind"] == JOB_MIRROR:
return await _deliver_mirror(database, job, handoff, source)
if job["kind"] == JOB_RELAY:
return await _deliver_relay(database, job, handoff, source)
raise HandoffPreflightError(f"Unknown handoff delivery kind: {job['kind']}")

View file

@ -10,7 +10,16 @@ from app.config import get_settings
from app.database import SessionFactory, mongo_db
from app.models import Account
from app.services.external_reply_service import log_original_external_message
from app.services.handoff_mirror_policy import (
GENERATED_BY_CUSTOMER,
MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
)
from app.services.human_handoff_service import MESSAGE_SCOPE_EXTERNAL
from app.services.human_handoff_service import (
maybe_trigger_handoff,
process_human_reply,
resolve_handoff_from_inbound,
)
from app.services.message_ids import canonical_message_id, message_id_fields
from app.services.mime_parser import parse_mime
from app.services.reply_extractor import build_reply_display
@ -121,26 +130,65 @@ class InboundHandler:
body_text = parsed.pop("body_text")
body_html = parsed.pop("body_html")
stored = await get_raw_mime_storage().put(raw, delivery_key)
raw_mime = {
"bucket": stored.bucket,
"name": stored.name,
"generation": stored.generation,
"size": stored.size,
"crc32c": stored.crc32c,
}
now = datetime.now(timezone.utc)
sender_address = (parsed.get("sender") or {}).get("address", "").lower()
internet_message_id = canonical_message_id(parsed.get("message_id"))
id_fields = message_id_fields(internet_message_id)
external_recipients: list[str] = []
for recipient in recipients:
account_id = envelope.account_ids[recipient]
handoff = await resolve_handoff_from_inbound(
mongo_db,
account_id=account_id,
agent_email=recipient,
sender_address=sender_address,
in_reply_to=parsed.get("in_reply_to"),
references=parsed.get("references", []),
)
if handoff is None:
external_recipients.append(recipient)
continue
await process_human_reply(
mongo_db,
account_id=account_id,
agent_email=recipient,
handoff=handoff,
parsed=parsed,
body_text=body_text,
body_html=body_html,
raw_mime=raw_mime,
delivery_key=delivery_key,
)
logger.info(
"handoff_reply_received account_id=%s handoff_id=%s message_id=%s",
account_id,
handoff["_id"],
internet_message_id,
)
if not external_recipients:
return f"250 2.0.0 Handoff message accepted as {delivery_key}"
recipients = external_recipients
message = {
**parsed,
"direction": "inbound",
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"message_origin": MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
"generated_by": GENERATED_BY_CUSTOMER,
"envelope_sender": envelope.mail_from.lower(),
"envelope_recipients": recipients,
"delivery_key": delivery_key,
**id_fields,
"raw_mime": {
"bucket": stored.bucket,
"name": stored.name,
"generation": stored.generation,
"size": stored.size,
"crc32c": stored.crc32c,
},
"raw_mime": raw_mime,
"received_at": now,
"created_at": now,
"has_text_body": bool(body_text),
@ -275,6 +323,21 @@ class InboundHandler:
thread_id,
mailbox_result.upserted_id is not None or mailbox_result.modified_count > 0,
)
try:
await maybe_trigger_handoff(
mongo_db,
account_id=account_id,
agent_email=recipient,
external_message=persisted,
external_thread_id=thread_id,
)
except Exception:
logger.exception(
"handoff_trigger_failed account_id=%s thread_id=%s message_id=%s",
account_id,
thread_id,
persisted["_id"],
)
except Exception:
logger.exception("Temporary failure accepting inbound message")
return "451 4.3.0 Temporary server failure; please retry"

View file

@ -16,6 +16,36 @@ from app.services.mime_body_extractor import ExtractedMimePart
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
logger = logging.getLogger(__name__)
SYSTEM_SENDER_LOCALS = {
"invite",
"invites",
"notification",
"notifications",
"no-reply",
"noreply",
"support",
}
def _sender_identity(sender: str, sender_name: str | None = None) -> tuple[str, str]:
"""Return an RFC-compliant From header and the bare SMTP envelope address."""
if "\r" in sender or "\n" in sender or (sender_name and ("\r" in sender_name or "\n" in sender_name)):
raise ValueError("Sender identity cannot contain line breaks.")
existing_name, address = email.utils.parseaddr(sender)
address = address.strip().lower()
if not address or "@" not in address:
raise ValueError("A valid sender email address is required.")
display_name = (sender_name or existing_name).strip()
if not display_name:
local_part = address.split("@", 1)[0]
if local_part in SYSTEM_SENDER_LOCALS:
display_name = "aGentic Email"
else:
display_name = local_part.replace(".", " ").replace("_", " ").replace("-", " ").title()
return email.utils.formataddr((display_name, address), charset="utf-8"), address
def log_outbound_mime_headers(message: Message) -> None:
@ -62,6 +92,7 @@ def _mime_attachment(part: ExtractedMimePart) -> MIMEBase:
def build_message(
*,
sender: str,
sender_name: str | None = None,
recipients: list[str],
cc: list[str],
subject: str,
@ -76,10 +107,11 @@ def build_message(
) -> Message:
inline_parts = inline_parts or []
file_attachments = file_attachments or []
sender_header, sender_address = _sender_identity(sender, sender_name)
if inline_parts or file_attachments:
root = MIMEMultipart("mixed", policy=SMTP_POLICY)
root["From"] = sender
root["From"] = sender_header
root["To"] = ", ".join(recipients)
if cc:
root["Cc"] = ", ".join(cc)
@ -87,7 +119,7 @@ def build_message(
root["Reply-To"] = reply_to
root["Subject"] = subject
root["Date"] = email.utils.formatdate(localtime=True)
generated_id = message_id or email.utils.make_msgid(domain=sender.split("@")[-1])
generated_id = message_id or email.utils.make_msgid(domain=sender_address.split("@")[-1])
root["Message-ID"] = generated_id
if in_reply_to:
root["In-Reply-To"] = in_reply_to
@ -115,7 +147,7 @@ def build_message(
return root
message = EmailMessage(policy=SMTP_POLICY)
message["From"] = sender
message["From"] = sender_header
message["To"] = ", ".join(recipients)
if cc:
message["Cc"] = ", ".join(cc)
@ -123,7 +155,7 @@ def build_message(
message["Reply-To"] = reply_to
message["Subject"] = subject
message["Date"] = email.utils.formatdate(localtime=True)
generated_id = message_id or email.utils.make_msgid(domain=sender.split("@")[-1])
generated_id = message_id or email.utils.make_msgid(domain=sender_address.split("@")[-1])
message["Message-ID"] = generated_id
if in_reply_to:
message["In-Reply-To"] = in_reply_to
@ -191,6 +223,7 @@ async def send_via_smtp(
async def deliver_email(
*,
sender: str,
sender_name: str | None = None,
recipients: list[str],
cc: list[str],
bcc: list[str],
@ -205,8 +238,10 @@ async def deliver_email(
inline_parts: list[ExtractedMimePart] | None = None,
file_attachments: list[ExtractedMimePart] | None = None,
) -> str:
_, sender_address = _sender_identity(sender, sender_name)
message = build_message(
sender=sender,
sender_name=sender_name,
recipients=recipients,
cc=cc,
subject=subject,
@ -224,7 +259,7 @@ async def deliver_email(
log_signed_mime_headers(signed)
await send_via_smtp(
signed,
sender=sender,
sender=sender_address,
recipients=[*recipients, *cc, *bcc],
)
return str(message["Message-ID"])

View file

@ -2,6 +2,10 @@ from dataclasses import dataclass, field
from email import policy
from email.message import EmailMessage, Message
from email.parser import BytesParser
from html.parser import HTMLParser
import re
from app.services.reply_extractor import extract_new_reply_content
@dataclass
@ -104,3 +108,109 @@ def extract_mirror_content(raw: bytes) -> MirroredMimeContent:
collector = MirroredMimeContent()
_walk_parts(message, collector)
return collector
class _NewReplyHTMLParser(HTMLParser):
QUOTE_CLASSES = {
"gmail_attr",
"gmail_quote",
"protonmail_quote",
"yahoo_quoted",
"moz-cite-prefix",
}
QUOTE_IDS = {"divRplyFwdMsg", "appendonsend"}
VOID_TAGS = {"br", "hr", "img", "meta", "link", "input"}
def __init__(self) -> None:
super().__init__(convert_charrefs=False)
self.output: list[str] = []
self.open_tags: list[str] = []
self.stopped = False
def _is_quote_start(self, tag: str, attrs: list[tuple[str, str | None]]) -> bool:
values = {key.lower(): (value or "") for key, value in attrs}
classes = set(values.get("class", "").split())
return (
tag.lower() == "blockquote"
or bool(classes.intersection(self.QUOTE_CLASSES))
or values.get("id") in self.QUOTE_IDS
)
def handle_starttag(self, tag: str, attrs) -> None:
if self.stopped:
return
if self._is_quote_start(tag, attrs):
self.stopped = True
return
self.output.append(self.get_starttag_text())
if tag.lower() not in self.VOID_TAGS:
self.open_tags.append(tag)
def handle_startendtag(self, tag: str, attrs) -> None:
if not self.stopped:
self.output.append(self.get_starttag_text())
def handle_endtag(self, tag: str) -> None:
if self.stopped:
return
self.output.append(f"</{tag}>")
if tag in self.open_tags:
reverse_index = self.open_tags[::-1].index(tag)
del self.open_tags[len(self.open_tags) - reverse_index - 1]
def handle_data(self, data: str) -> None:
if self.stopped:
return
if re.search(r"(?:^|\n)\s*On .+? wrote:\s*$", data, re.IGNORECASE):
self.stopped = True
return
self.output.append(data)
def handle_entityref(self, name: str) -> None:
if not self.stopped:
self.output.append(f"&{name};")
def handle_charref(self, name: str) -> None:
if not self.stopped:
self.output.append(f"&#{name};")
def html(self) -> str:
value = "".join(self.output).strip()
if not value:
return ""
return value + "".join(f"</{tag}>" for tag in reversed(self.open_tags))
def _new_reply_html(html: str | None) -> str | None:
if not html:
return None
parser = _NewReplyHTMLParser()
parser.feed(html)
parser.close()
value = parser.html().strip()
return value or None
def extract_new_reply_mime_content(raw: bytes) -> MirroredMimeContent:
"""Extract only newly authored MIME content while retaining rich formatting."""
full = extract_mirror_content(raw)
extracted = extract_new_reply_content(
body_text=full.body_text,
body_html=full.body_html,
)
new_html = _new_reply_html(full.body_html)
referenced_cids = {
match.casefold()
for match in re.findall(r"cid:([^\"'\s>)]+)", new_html or "", re.IGNORECASE)
}
inline_parts = [
part
for part in full.inline_parts
if part.content_id and part.content_id.casefold() in referenced_cids
]
return MirroredMimeContent(
body_text=extracted["new_reply_content"].strip(),
body_html=new_html,
inline_parts=inline_parts,
file_attachments=full.file_attachments,
)

View file

@ -94,6 +94,11 @@ def parse_mime(raw: bytes) -> dict:
"to": _header_str(message.get("To")),
"cc": _header_str(message.get("Cc")),
"reply_to": _header_str(message.get("Reply-To")),
"auto_submitted": _header_str(message.get("Auto-Submitted")),
"precedence": _header_str(message.get("Precedence")),
"x_auto_response_suppress": _header_str(
message.get("X-Auto-Response-Suppress")
),
}
return {
"message_id": internet_message_id,

View file

@ -44,9 +44,10 @@ async def send_employee_invitation(
expires_hours=INVITE_EXPIRY_HOURS,
)
sender = f"{BRAND_NAME} <{INVITE_FROM_LOCAL}@{settings.platform_domain}>"
sender = f"{INVITE_FROM_LOCAL}@{settings.platform_domain}"
await deliver_email(
sender=sender,
sender_name=BRAND_NAME,
recipients=[member.invitation_email],
cc=[],
bcc=[],

View file

@ -16,6 +16,7 @@ TEXT_QUOTE_MARKERS = [
PRIVATE_LEAK_MARKERS = [
"human handoff",
" · handoff · ",
"a conversation needs your attention",
"why this was handed off",
"why you're receiving this",
@ -37,11 +38,19 @@ def _normalize_newlines(text: str) -> str:
class _ReplyHTMLTextParser(HTMLParser):
BLOCK_TAGS = {"div", "p", "pre", "li", "ul", "ol", "table", "tr"}
IGNORED_TAGS = {"script", "style", "iframe", "object"}
QUOTE_CLASSES = {
"gmail_attr",
"gmail_quote",
"protonmail_quote",
"yahoo_quoted",
"moz-cite-prefix",
}
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.quote_depth = 0
self.quote_tags: list[str] = []
self.ignored_depth = 0
self.at_line_start = True
@ -70,11 +79,19 @@ class _ReplyHTMLTextParser(HTMLParser):
return
if self.ignored_depth:
return
classes = {
value
for key, value in attrs
if key.lower() == "class"
for value in (value or "").split()
}
is_quote = tag == "blockquote" or bool(classes.intersection(self.QUOTE_CLASSES))
if tag == "br":
self._newline()
elif tag == "blockquote":
elif is_quote:
self._newline()
self.quote_depth += 1
self.quote_tags.append(tag)
elif tag in self.BLOCK_TAGS:
self._newline()
@ -85,9 +102,10 @@ class _ReplyHTMLTextParser(HTMLParser):
return
if self.ignored_depth:
return
if tag == "blockquote":
self._newline()
if self.quote_tags and self.quote_tags[-1] == tag:
self.quote_tags.pop()
self.quote_depth = max(0, self.quote_depth - 1)
self._newline()
elif tag in self.BLOCK_TAGS:
self._newline()

View file

@ -31,9 +31,10 @@ async def send_welcome_email(session: AsyncSession, account: Account) -> None:
login_url=login_url,
is_company_account=account.account_type == "company",
)
sender = f"AGMail Team <{NOTIFICATIONS_FROM_LOCAL}@{settings.platform_domain}>"
sender = f"{NOTIFICATIONS_FROM_LOCAL}@{settings.platform_domain}"
await deliver_email(
sender=sender,
sender_name=BRAND_NAME,
recipients=[account.email],
cc=[],
bcc=[],