human hand off
This commit is contained in:
parent
d0fee5c8f7
commit
7af776196a
34 changed files with 2941 additions and 195 deletions
|
|
@ -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']}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue