human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
|
|
@ -8,6 +8,7 @@ from app.config import get_settings
|
|||
from app.database import SessionFactory, engine, mongo_client, mongo_db
|
||||
from app.models import Base
|
||||
from app.routers import (
|
||||
agent_handoff,
|
||||
aliases,
|
||||
auth,
|
||||
dns_setup,
|
||||
|
|
@ -18,7 +19,9 @@ from app.routers import (
|
|||
people,
|
||||
sections,
|
||||
sticky_notes,
|
||||
threads,
|
||||
)
|
||||
from app.services.thread_service import create_thread, message_event_at
|
||||
from app.services.domain_provisioning import ensure_platform_domain
|
||||
from app.services.inbound_smtp import InboundSMTPServer
|
||||
|
||||
|
|
@ -26,6 +29,25 @@ settings = get_settings()
|
|||
inbound_server = InboundSMTPServer()
|
||||
|
||||
|
||||
async def backfill_missing_thread_ids(database) -> None:
|
||||
cursor = database.mailbox_entries.find({"thread_id": None})
|
||||
async for entry in cursor:
|
||||
message = await database.messages.find_one({"_id": entry["message_id"]})
|
||||
if message is None:
|
||||
continue
|
||||
thread_id = await create_thread(
|
||||
database,
|
||||
account_id=entry["account_id"],
|
||||
subject=entry.get("subject") or message.get("subject", ""),
|
||||
message_id=entry["message_id"],
|
||||
event_at=entry.get("event_at") or message_event_at(message),
|
||||
)
|
||||
await database.mailbox_entries.update_one(
|
||||
{"_id": entry["_id"]},
|
||||
{"$set": {"thread_id": thread_id}},
|
||||
)
|
||||
|
||||
|
||||
async def migrate_mail_group_columns(connection) -> None:
|
||||
await connection.execute(
|
||||
text(
|
||||
|
|
@ -168,6 +190,39 @@ async def lifespan(_: FastAPI):
|
|||
],
|
||||
name="mailbox_search",
|
||||
)
|
||||
await mongo_db.messages.create_index(
|
||||
[("message_id", 1)],
|
||||
sparse=True,
|
||||
)
|
||||
await mongo_db.mailbox_entries.create_index(
|
||||
[("account_id", 1), ("thread_id", 1), ("event_at", -1), ("_id", -1)]
|
||||
)
|
||||
await mongo_db.email_threads.create_index(
|
||||
[("account_id", 1), ("last_message_at", -1), ("_id", -1)]
|
||||
)
|
||||
await mongo_db.human_handoff_rules.create_index(
|
||||
[("account_id", 1), ("updated_at", -1)]
|
||||
)
|
||||
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)]
|
||||
)
|
||||
await mongo_db.human_handoffs.create_index(
|
||||
[("account_id", 1), ("handoff_thread_id", 1)]
|
||||
)
|
||||
await mongo_db.handoff_message_links.create_index(
|
||||
[("handoff_id", 1), ("created_at", -1)]
|
||||
)
|
||||
await mongo_db.mailbox_entries.create_index(
|
||||
[("account_id", 1), ("message_scope", 1), ("mailbox", 1), ("event_at", -1)]
|
||||
)
|
||||
await mongo_db.messages.create_index(
|
||||
[("message_id", 1), ("message_scope", 1)],
|
||||
sparse=True,
|
||||
)
|
||||
await backfill_missing_thread_ids(mongo_db)
|
||||
if settings.inbound_smtp_enabled:
|
||||
await inbound_server.start()
|
||||
try:
|
||||
|
|
@ -192,6 +247,8 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(emails.router, prefix=settings.api_prefix)
|
||||
app.include_router(threads.router, prefix=settings.api_prefix)
|
||||
app.include_router(agent_handoff.router, prefix=settings.api_prefix)
|
||||
app.include_router(sticky_notes.router, prefix=settings.api_prefix)
|
||||
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||
app.include_router(domains.router, prefix=settings.api_prefix)
|
||||
|
|
|
|||
309
backend/app/routers/agent_handoff.py
Normal file
309
backend/app/routers/agent_handoff.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import re
|
||||
from datetime import datetime, timezone
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import EmailStr, Field
|
||||
|
||||
from app.database import get_mongo_database
|
||||
from app.models import Account
|
||||
from app.routers.auth import get_current_account
|
||||
from app.schemas import (
|
||||
HandoffConversationRead,
|
||||
HandoffConversationSummary,
|
||||
HandoffLinkedExternal,
|
||||
HandoffMessageRead,
|
||||
HandoffRuleCreate,
|
||||
HandoffRuleRead,
|
||||
HandoffRuleUpdate,
|
||||
HandoffTestRequest,
|
||||
HandoffTestResult,
|
||||
)
|
||||
from app.services.human_handoff_service import MESSAGE_SCOPE_HANDOFF
|
||||
from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff
|
||||
|
||||
router = APIRouter(prefix="/agent/handoff", tags=["agent-handoff"])
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _owner_name_from_email(email: str) -> str:
|
||||
local = email.split("@", 1)[0]
|
||||
return re.sub(r"[._-]+", " ", local).strip().title() or email
|
||||
|
||||
|
||||
def _status_label(status: str, current_owner: str) -> str:
|
||||
if status == "WAITING_FOR_HUMAN":
|
||||
return "Waiting for Human"
|
||||
if status == "HUMAN_REPLIED":
|
||||
return "Needs Agent Reply"
|
||||
if status == "AGENT_REPLIED":
|
||||
return "Replied"
|
||||
if status == "CLOSED":
|
||||
return "Closed"
|
||||
if current_owner == "HUMAN":
|
||||
return "Waiting for Human"
|
||||
return "Active"
|
||||
|
||||
|
||||
def _serialize(document: dict) -> HandoffRuleRead:
|
||||
return HandoffRuleRead(
|
||||
id=str(document["_id"]),
|
||||
owner_email=document["owner_email"],
|
||||
owner_name=document.get("owner_name") or _owner_name_from_email(document["owner_email"]),
|
||||
labels=document.get("labels", []),
|
||||
email_types=document.get("email_types", []),
|
||||
instructions=document.get("instructions", ""),
|
||||
allow_replies=document.get("allow_replies", True),
|
||||
reply_as=document.get("reply_as", "agent"),
|
||||
active=document.get("active", True),
|
||||
created_at=document["created_at"],
|
||||
updated_at=document["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[HandoffRuleRead])
|
||||
async def list_handoff_rules(
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
cursor = database.human_handoff_rules.find(
|
||||
{"account_id": str(account.id)}
|
||||
).sort("updated_at", -1)
|
||||
return [_serialize(document) async for document in cursor]
|
||||
|
||||
|
||||
@router.post("", response_model=HandoffRuleRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_handoff_rule(
|
||||
payload: HandoffRuleCreate,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
now = _now()
|
||||
document = {
|
||||
"_id": ObjectId(),
|
||||
"account_id": str(account.id),
|
||||
"owner_email": str(payload.owner_email).lower(),
|
||||
"owner_name": payload.owner_name or _owner_name_from_email(str(payload.owner_email)),
|
||||
"labels": payload.labels,
|
||||
"email_types": payload.email_types,
|
||||
"instructions": payload.instructions.strip(),
|
||||
"allow_replies": payload.allow_replies,
|
||||
"reply_as": payload.reply_as,
|
||||
"active": payload.active,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
await database.human_handoff_rules.insert_one(document)
|
||||
return _serialize(document)
|
||||
|
||||
|
||||
@router.patch("/{rule_id}", response_model=HandoffRuleRead)
|
||||
async def update_handoff_rule(
|
||||
rule_id: str,
|
||||
payload: HandoffRuleUpdate,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
if not ObjectId.is_valid(rule_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.")
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
if "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,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.")
|
||||
return _serialize(result)
|
||||
|
||||
|
||||
@router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_handoff_rule(
|
||||
rule_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
if not ObjectId.is_valid(rule_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.")
|
||||
result = await database.human_handoff_rules.delete_one(
|
||||
{"_id": ObjectId(rule_id), "account_id": str(account.id)}
|
||||
)
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.")
|
||||
|
||||
|
||||
@router.post("/test", response_model=HandoffTestResult)
|
||||
async def test_handoff(
|
||||
payload: HandoffTestRequest,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
labels = payload.labels
|
||||
email_types = payload.email_types
|
||||
instructions = payload.instructions
|
||||
owner_email = payload.owner_email
|
||||
|
||||
if payload.rule_id:
|
||||
if not ObjectId.is_valid(payload.rule_id):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid rule ID.")
|
||||
rule = await database.human_handoff_rules.find_one(
|
||||
{"_id": ObjectId(payload.rule_id), "account_id": str(account.id)}
|
||||
)
|
||||
if rule is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Handoff rule not found.")
|
||||
labels = rule.get("labels", [])
|
||||
email_types = rule.get("email_types", [])
|
||||
instructions = rule.get("instructions", "")
|
||||
owner_email = rule.get("owner_email", owner_email)
|
||||
|
||||
evaluation = evaluate_handoff(
|
||||
email=payload.email.model_dump(),
|
||||
labels=labels,
|
||||
email_types=email_types,
|
||||
instructions=instructions or "",
|
||||
)
|
||||
return HandoffTestResult(
|
||||
owner_email=owner_email,
|
||||
**evaluation,
|
||||
result_path="Agent → Human Owner" if evaluation["would_handoff"] else "Agent handles email",
|
||||
reason=evaluation["instruction_reason"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=list[HandoffConversationSummary])
|
||||
async def list_handoff_conversations(
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
account_id = str(account.id)
|
||||
cursor = database.human_handoffs.find({"account_id": account_id}).sort("updated_at", -1)
|
||||
items: list[HandoffConversationSummary] = []
|
||||
async for handoff in cursor:
|
||||
handoff_thread_id = handoff["handoff_thread_id"]
|
||||
count = await database.mailbox_entries.count_documents(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"thread_id": handoff_thread_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
)
|
||||
latest = await database.mailbox_entries.find_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"thread_id": handoff_thread_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
},
|
||||
sort=[("event_at", -1), ("_id", -1)],
|
||||
)
|
||||
snippet = latest.get("snippet", "") if latest else ""
|
||||
items.append(
|
||||
HandoffConversationSummary(
|
||||
id=str(handoff["_id"]),
|
||||
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=_status_label(
|
||||
handoff.get("status", "ACTIVE"),
|
||||
handoff.get("current_owner", "AGENT"),
|
||||
),
|
||||
current_owner=handoff.get("current_owner", "AGENT"),
|
||||
snippet=snippet,
|
||||
message_count=count,
|
||||
updated_at=handoff.get("updated_at") or handoff.get("created_at"),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@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.")
|
||||
|
||||
pipeline = [
|
||||
{
|
||||
"$match": {
|
||||
"account_id": account_id,
|
||||
"thread_id": handoff["handoff_thread_id"],
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
},
|
||||
{"$sort": {"event_at": 1, "_id": 1}},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
|
||||
human_email = handoff.get("human_email", "").lower()
|
||||
messages: list[HandoffMessageRead] = []
|
||||
for row in rows:
|
||||
message = row["message"]
|
||||
sender_address = (message.get("sender") or {}).get("address", "").lower()
|
||||
role = "human" if sender_address == human_email else "agent"
|
||||
event_at = row.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"]
|
||||
messages.append(
|
||||
HandoffMessageRead(
|
||||
id=str(message["_id"]),
|
||||
role=role,
|
||||
subject=message.get("subject", ""),
|
||||
snippet=message.get("snippet", ""),
|
||||
body_text=message.get("body_text", ""),
|
||||
event_at=event_at,
|
||||
)
|
||||
)
|
||||
|
||||
return HandoffConversationRead(
|
||||
id=str(handoff["_id"]),
|
||||
external_subject=handoff.get("external_subject", ""),
|
||||
external_sender=handoff.get("external_sender", ""),
|
||||
human_email=handoff.get("human_email", ""),
|
||||
handoff_reason=handoff.get("handoff_reason", ""),
|
||||
status=handoff.get("status", "ACTIVE"),
|
||||
status_label=_status_label(
|
||||
handoff.get("status", "ACTIVE"),
|
||||
handoff.get("current_owner", "AGENT"),
|
||||
),
|
||||
current_owner=handoff.get("current_owner", "AGENT"),
|
||||
linked_external=HandoffLinkedExternal(
|
||||
thread_id=str(handoff["external_thread_id"]),
|
||||
subject=handoff.get("external_subject", ""),
|
||||
sender=handoff.get("external_sender", ""),
|
||||
),
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meta")
|
||||
async def handoff_meta():
|
||||
return {
|
||||
"labels": HANDOFF_LABELS,
|
||||
"email_types": [
|
||||
{
|
||||
"id": key,
|
||||
"title": meta["title"],
|
||||
}
|
||||
for key, meta in EMAIL_TYPE_META.items()
|
||||
],
|
||||
}
|
||||
|
|
@ -24,8 +24,20 @@ from app.schemas import (
|
|||
)
|
||||
from app.services.domain_provisioning import get_domain_by_name
|
||||
from app.services.mail_sender import deliver_email
|
||||
from app.services.message_ids import normalize_message_id
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.raw_mime_storage import get_raw_mime_storage
|
||||
from app.services.reply_recipients import reply_recipients
|
||||
from app.services.thread_service import (
|
||||
create_thread,
|
||||
ensure_thread_for_message,
|
||||
message_event_at,
|
||||
threading_fields_from_parent,
|
||||
)
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.thread_resolver import ThreadResolution
|
||||
|
||||
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
|
||||
|
||||
router = APIRouter(prefix="/emails", tags=["emails"])
|
||||
|
||||
|
|
@ -93,6 +105,7 @@ def summarize(message: dict, entry: dict) -> MessageSummary:
|
|||
is_starred=entry.get("is_starred", False),
|
||||
trashed_at=entry.get("trashed_at"),
|
||||
attachments=message.get("attachments", []),
|
||||
thread_id=str(entry["thread_id"]) if entry.get("thread_id") else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -117,10 +130,44 @@ async def create_email(
|
|||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
sender = account.email.lower()
|
||||
recipients = [str(recipient).lower() for recipient in payload.recipients]
|
||||
cc = [str(recipient).lower() for recipient in payload.cc]
|
||||
parent_message = None
|
||||
thread_id = None
|
||||
threading_fields: dict = {}
|
||||
if payload.reply_to_message_id:
|
||||
if not ObjectId.is_valid(payload.reply_to_message_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid reply target.",
|
||||
)
|
||||
parent_id = ObjectId(payload.reply_to_message_id)
|
||||
parent_entry = await database.mailbox_entries.find_one(
|
||||
{"account_id": str(account.id), "message_id": parent_id}
|
||||
)
|
||||
parent_message = await database.messages.find_one({"_id": parent_id})
|
||||
if parent_message is None or parent_entry is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Reply target not found.",
|
||||
)
|
||||
thread_id = parent_entry.get("thread_id")
|
||||
threading_fields = threading_fields_from_parent(parent_message)
|
||||
to_addresses, cc_addresses = reply_recipients(
|
||||
message=parent_message,
|
||||
viewer_email=sender,
|
||||
reply_all=payload.reply_all,
|
||||
)
|
||||
recipients = to_addresses or [str(recipient).lower() for recipient in payload.recipients]
|
||||
cc = cc_addresses or [str(recipient).lower() for recipient in payload.cc]
|
||||
else:
|
||||
recipients = [str(recipient).lower() for recipient in payload.recipients]
|
||||
cc = [str(recipient).lower() for recipient in payload.cc]
|
||||
bcc = [str(recipient).lower() for recipient in payload.bcc]
|
||||
all_recipients = [*recipients, *cc, *bcc]
|
||||
if not all_recipients:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="At least one recipient is required.",
|
||||
)
|
||||
if len(set(all_recipients)) != len(all_recipients):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -139,8 +186,13 @@ async def create_email(
|
|||
)
|
||||
|
||||
created_at = utc_now()
|
||||
subject = payload.subject
|
||||
if parent_message and not subject.strip():
|
||||
parent_subject = parent_message.get("subject", "")
|
||||
subject = parent_subject if parent_subject.lower().startswith("re:") else f"Re: {parent_subject}"
|
||||
document = {
|
||||
"direction": "outbound",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"account_id": str(account.id),
|
||||
"sender": address(
|
||||
sender,
|
||||
|
|
@ -149,17 +201,21 @@ async def create_email(
|
|||
"to": [address(value) for value in recipients],
|
||||
"cc": [address(value) for value in cc],
|
||||
"bcc": [address(value) for value in bcc],
|
||||
"subject": payload.subject,
|
||||
"subject": subject,
|
||||
"snippet": re.sub(r"\s+", " ", payload.body_text).strip()[:240],
|
||||
"body_text": payload.body_text,
|
||||
"body_html": payload.body_html,
|
||||
"attachments": [],
|
||||
"created_at": created_at,
|
||||
"sent_at": created_at,
|
||||
"status": "queued",
|
||||
**threading_fields,
|
||||
"thread_relation": "reply" if parent_message else "new_thread",
|
||||
"thread_resolution_method": "in_reply_to" if parent_message else "new_thread",
|
||||
}
|
||||
|
||||
try:
|
||||
await deliver_email(
|
||||
internet_message_id = await deliver_email(
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
|
|
@ -168,19 +224,45 @@ async def create_email(
|
|||
body_text=document["body_text"],
|
||||
body_html=document.get("body_html"),
|
||||
domain=domain,
|
||||
in_reply_to=threading_fields.get("in_reply_to"),
|
||||
references=threading_fields.get("references"),
|
||||
)
|
||||
document["status"] = "accepted"
|
||||
document["message_id"] = normalize_message_id(internet_message_id)
|
||||
except Exception as error:
|
||||
document["status"] = "failed"
|
||||
document["error"] = str(error)
|
||||
|
||||
result = await database.messages.insert_one(document)
|
||||
document["_id"] = result.inserted_id
|
||||
if parent_message and thread_id:
|
||||
resolution = ThreadResolution(
|
||||
thread_id=thread_id,
|
||||
parent_message_id=parent_message["_id"],
|
||||
thread_relation="reply",
|
||||
thread_resolution_method="in_reply_to",
|
||||
)
|
||||
thread_id = await ensure_thread_for_message(
|
||||
database,
|
||||
account_id=str(account.id),
|
||||
message=document,
|
||||
resolution=resolution,
|
||||
)
|
||||
else:
|
||||
thread_id = await create_thread(
|
||||
database,
|
||||
account_id=str(account.id),
|
||||
subject=document["subject"],
|
||||
message_id=result.inserted_id,
|
||||
event_at=message_event_at(document),
|
||||
)
|
||||
await database.mailbox_entries.insert_one(
|
||||
{
|
||||
"account_id": str(account.id),
|
||||
"message_id": result.inserted_id,
|
||||
"thread_id": thread_id,
|
||||
"mailbox": "sent",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"sender": document["sender"],
|
||||
"recipients": document["to"],
|
||||
|
|
@ -207,7 +289,7 @@ async def create_email(
|
|||
recipients=recipients,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
subject=payload.subject,
|
||||
subject=subject,
|
||||
body_text=payload.body_text,
|
||||
body_html=payload.body_html,
|
||||
status=document["status"],
|
||||
|
|
@ -236,6 +318,10 @@ async def get_email(
|
|||
if entry is not None
|
||||
else None
|
||||
)
|
||||
if entry is not None and (
|
||||
entry.get("message_scope") == "HANDOFF" or entry.get("mailbox") == "handoff"
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email not found.")
|
||||
if document is None or entry is None:
|
||||
legacy = await legacy_email(database, message_id, viewer_email)
|
||||
if legacy is not None:
|
||||
|
|
@ -260,6 +346,12 @@ async def get_email(
|
|||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
message_id=document.get("message_id"),
|
||||
in_reply_to=document.get("in_reply_to"),
|
||||
references=document.get("references", []),
|
||||
parent_message_id=str(document["parent_message_id"])
|
||||
if document.get("parent_message_id")
|
||||
else None,
|
||||
thread_relation=document.get("thread_relation"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -309,6 +401,7 @@ async def list_emails(
|
|||
entry_match["event_at"] = event_range
|
||||
if q and q.strip():
|
||||
entry_match["$text"] = {"$search": q.strip()}
|
||||
apply_external_scope(entry_match)
|
||||
if cursor:
|
||||
created_at, entry_id = decode_cursor(cursor)
|
||||
cursor_clause = [
|
||||
|
|
|
|||
314
backend/app/routers/threads.py
Normal file
314
backend/app/routers/threads.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.database import get_mongo_database
|
||||
from app.models import Account
|
||||
from app.routers.auth import get_current_account
|
||||
from app.routers.emails import decode_cursor, encode_cursor, summarize
|
||||
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.raw_mime_storage import get_raw_mime_storage
|
||||
|
||||
router = APIRouter(prefix="/threads", tags=["threads"])
|
||||
|
||||
|
||||
def encode_thread_cursor(thread: dict) -> str:
|
||||
value = json.dumps(
|
||||
{
|
||||
"latest_at": thread["latest_at"].isoformat(),
|
||||
"id": str(thread["_id"]),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_thread_cursor(value: str) -> tuple[datetime, ObjectId]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return datetime.fromisoformat(payload["latest_at"]), ObjectId(payload["id"])
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid cursor.",
|
||||
) from error
|
||||
|
||||
|
||||
def participant_key(address: dict) -> str:
|
||||
return address.get("address", "").lower()
|
||||
|
||||
|
||||
def unique_addresses(entries: list[dict]) -> list[dict]:
|
||||
seen: set[str] = set()
|
||||
values: list[dict] = []
|
||||
for entry in entries:
|
||||
for key in ("sender",):
|
||||
address = entry.get(key)
|
||||
if isinstance(address, dict):
|
||||
token = participant_key(address)
|
||||
if token and token not in seen:
|
||||
seen.add(token)
|
||||
values.append(address)
|
||||
return values
|
||||
|
||||
|
||||
def thread_summary_from_group(row: dict, mailbox: str) -> ThreadSummary:
|
||||
latest_entry = row["latest_entry"]
|
||||
latest_message = latest_entry["message"]
|
||||
senders = unique_addresses(row.get("entries", [latest_entry]))
|
||||
participants = senders[:]
|
||||
for entry in row.get("entries", []):
|
||||
for recipient in entry.get("recipients", []):
|
||||
token = participant_key(recipient)
|
||||
if token and token not in {participant_key(item) for item in participants}:
|
||||
participants.append(recipient)
|
||||
latest_at = latest_entry.get("event_at") or latest_message.get("received_at") or latest_message["created_at"]
|
||||
return ThreadSummary(
|
||||
id=str(row["_id"]),
|
||||
mailbox=mailbox,
|
||||
subject=latest_message.get("subject", ""),
|
||||
snippet=latest_message.get("snippet", ""),
|
||||
participants=[{"name": item.get("name", ""), "address": item.get("address", "")} for item in participants[:6]],
|
||||
message_count=row.get("message_count", 1),
|
||||
unread_count=row.get("unread_count", 0),
|
||||
is_read=row.get("unread_count", 0) == 0,
|
||||
is_starred=bool(row.get("is_starred")),
|
||||
has_attachments=bool(row.get("has_attachments")),
|
||||
latest_message_id=str(latest_message["_id"]),
|
||||
latest_at=latest_at,
|
||||
senders=[{"name": item.get("name", ""), "address": item.get("address", "")} for item in senders[:4]],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ThreadPage)
|
||||
async def list_threads(
|
||||
mailbox: Annotated[str, Query(pattern="^(inbox|sent|trash)$")] = "inbox",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
||||
cursor: str | None = None,
|
||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
||||
is_read: bool | None = None,
|
||||
sender: Annotated[str | None, Query(max_length=320)] = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
entry_match: dict = {
|
||||
"account_id": str(account.id),
|
||||
"deletion_state": {"$in": [None, ""]},
|
||||
"thread_id": {"$ne": None},
|
||||
}
|
||||
if mailbox == "trash":
|
||||
entry_match["trashed_at"] = {"$ne": None}
|
||||
else:
|
||||
entry_match.update({"mailbox": mailbox, "trashed_at": None})
|
||||
if sender:
|
||||
pattern = f"^{re.escape(sender.strip())}"
|
||||
entry_match["$or"] = [
|
||||
{"sender.name": {"$regex": pattern, "$options": "i"}},
|
||||
{"sender.address": {"$regex": pattern, "$options": "i"}},
|
||||
]
|
||||
if date_from or date_to:
|
||||
event_range: dict = {}
|
||||
if date_from:
|
||||
event_range["$gte"] = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
||||
if date_to:
|
||||
event_range["$lt"] = datetime.combine(
|
||||
date_to + timedelta(days=1),
|
||||
time.min,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
entry_match["event_at"] = event_range
|
||||
apply_external_scope(entry_match)
|
||||
|
||||
pipeline: list[dict] = [
|
||||
{"$match": entry_match},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
if q and q.strip():
|
||||
pipeline.append(
|
||||
{
|
||||
"$match": {
|
||||
"$or": [
|
||||
{"subject": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
{"snippet": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
{"message.subject": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
{"message.snippet": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
pipeline.extend(
|
||||
[
|
||||
{
|
||||
"$group": {
|
||||
"_id": "$thread_id",
|
||||
"entries": {"$push": "$$ROOT"},
|
||||
"latest_at": {"$max": "$event_at"},
|
||||
"unread_count": {
|
||||
"$sum": {"$cond": [{"$eq": ["$is_read", False]}, 1, 0]}
|
||||
},
|
||||
"is_starred": {"$max": "$is_starred"},
|
||||
"has_attachments": {
|
||||
"$max": {
|
||||
"$gt": [
|
||||
{"$size": {"$ifNull": ["$attachments", []]}},
|
||||
0,
|
||||
]
|
||||
}
|
||||
},
|
||||
"message_count": {"$sum": 1},
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
if is_read is not None:
|
||||
pipeline.append(
|
||||
{
|
||||
"$match": {
|
||||
"unread_count": 0 if is_read else {"$gt": 0},
|
||||
}
|
||||
}
|
||||
)
|
||||
pipeline.append({"$sort": {"latest_at": -1, "_id": -1}})
|
||||
if cursor:
|
||||
latest_at, thread_id = decode_thread_cursor(cursor)
|
||||
pipeline.append(
|
||||
{
|
||||
"$match": {
|
||||
"$or": [
|
||||
{"latest_at": {"$lt": latest_at}},
|
||||
{"latest_at": latest_at, "_id": {"$lt": thread_id}},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
pipeline.append({"$limit": limit + 1})
|
||||
pipeline.append(
|
||||
{
|
||||
"$addFields": {
|
||||
"latest_entry": {
|
||||
"$first": {
|
||||
"$sortArray": {
|
||||
"input": "$entries",
|
||||
"sortBy": {"event_at": -1, "_id": -1},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
items = [thread_summary_from_group(row, mailbox) for row in rows]
|
||||
next_cursor = None
|
||||
if has_more and rows:
|
||||
next_cursor = encode_thread_cursor(
|
||||
{"latest_at": rows[-1]["latest_at"], "_id": rows[-1]["_id"]}
|
||||
)
|
||||
return ThreadPage(items=items, next_cursor=next_cursor)
|
||||
|
||||
|
||||
async def message_read(
|
||||
database,
|
||||
message: dict,
|
||||
entry: dict,
|
||||
account: Account,
|
||||
) -> MessageRead:
|
||||
body_text = message.get("body_text", "")
|
||||
body_html = message.get("body_html")
|
||||
if message.get("raw_mime"):
|
||||
raw = await get_raw_mime_storage().get(message["raw_mime"])
|
||||
parsed = parse_mime(raw)
|
||||
body_text = parsed["body_text"]
|
||||
body_html = parsed["body_html"]
|
||||
summary = summarize(message, entry)
|
||||
return MessageRead(
|
||||
**summary.model_dump(),
|
||||
cc=message.get("cc", []),
|
||||
bcc=message.get("bcc", []) if message.get("account_id") == str(account.id) else [],
|
||||
reply_to=message.get("reply_to", []),
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
message_id=message.get("message_id"),
|
||||
in_reply_to=message.get("in_reply_to"),
|
||||
references=message.get("references", []),
|
||||
parent_message_id=str(message["parent_message_id"])
|
||||
if message.get("parent_message_id")
|
||||
else None,
|
||||
thread_relation=message.get("thread_relation"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{thread_id}", response_model=ThreadRead)
|
||||
async def get_thread(
|
||||
thread_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
if not ObjectId.is_valid(thread_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid thread ID.",
|
||||
)
|
||||
thread_object_id = ObjectId(thread_id)
|
||||
thread = await database.email_threads.find_one(
|
||||
{"_id": thread_object_id, "account_id": str(account.id)}
|
||||
)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thread not found.")
|
||||
if thread.get("thread_type") == "HUMAN_HANDOFF":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thread not found.")
|
||||
|
||||
entry_match = {
|
||||
"account_id": str(account.id),
|
||||
"thread_id": thread_object_id,
|
||||
"deletion_state": {"$in": [None, ""]},
|
||||
}
|
||||
apply_external_scope(entry_match)
|
||||
pipeline = [
|
||||
{
|
||||
"$match": entry_match,
|
||||
},
|
||||
{"$sort": {"event_at": 1, "_id": 1}},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
|
||||
if not rows:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thread not found.")
|
||||
|
||||
messages = [
|
||||
await message_read(database, row["message"], row, account)
|
||||
for row in rows
|
||||
]
|
||||
return ThreadRead(
|
||||
thread_id=thread_id,
|
||||
subject=messages[-1].subject if messages else "",
|
||||
message_count=len(messages),
|
||||
messages=messages,
|
||||
)
|
||||
|
|
@ -224,6 +224,112 @@ class OrgSecuritySettingsRead(BaseModel):
|
|||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HandoffEmailSample(BaseModel):
|
||||
subject: str = ""
|
||||
snippet: str = ""
|
||||
body_text: str = ""
|
||||
is_starred: bool = False
|
||||
|
||||
|
||||
class HandoffRuleCreate(BaseModel):
|
||||
owner_email: EmailStr
|
||||
owner_name: str | None = None
|
||||
labels: list[str] = Field(default_factory=list)
|
||||
email_types: list[str] = Field(default_factory=list)
|
||||
instructions: str = ""
|
||||
allow_replies: bool = True
|
||||
reply_as: Literal["agent", "human"] = "agent"
|
||||
active: bool = True
|
||||
|
||||
|
||||
class HandoffRuleUpdate(BaseModel):
|
||||
owner_email: EmailStr | None = None
|
||||
owner_name: str | None = None
|
||||
labels: list[str] | None = None
|
||||
email_types: list[str] | None = None
|
||||
instructions: str | None = None
|
||||
allow_replies: bool | None = None
|
||||
reply_as: Literal["agent", "human"] | None = None
|
||||
active: bool | None = None
|
||||
|
||||
|
||||
class HandoffRuleRead(BaseModel):
|
||||
id: str
|
||||
owner_email: EmailStr
|
||||
owner_name: str
|
||||
labels: list[str]
|
||||
email_types: list[str]
|
||||
instructions: str
|
||||
allow_replies: bool
|
||||
reply_as: Literal["agent", "human"]
|
||||
active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HandoffTestRequest(BaseModel):
|
||||
rule_id: str | None = None
|
||||
owner_email: EmailStr | None = None
|
||||
labels: list[str] = Field(default_factory=list)
|
||||
email_types: list[str] = Field(default_factory=list)
|
||||
instructions: str = ""
|
||||
email: HandoffEmailSample
|
||||
|
||||
|
||||
class HandoffTestResult(BaseModel):
|
||||
owner_email: EmailStr | None = None
|
||||
label_matched: bool
|
||||
matched_labels: list[str]
|
||||
type_matched: bool
|
||||
matched_types: list[str]
|
||||
instruction_matched: bool
|
||||
instruction_reason: str
|
||||
would_handoff: bool
|
||||
result_path: str
|
||||
reason: str
|
||||
|
||||
|
||||
class HandoffLinkedExternal(BaseModel):
|
||||
thread_id: str
|
||||
subject: str
|
||||
sender: str
|
||||
|
||||
|
||||
class HandoffConversationSummary(BaseModel):
|
||||
id: str
|
||||
external_subject: str
|
||||
external_sender: str
|
||||
human_email: str
|
||||
status: str
|
||||
status_label: str
|
||||
current_owner: str
|
||||
snippet: str
|
||||
message_count: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HandoffMessageRead(BaseModel):
|
||||
id: str
|
||||
role: Literal["agent", "human"]
|
||||
subject: str
|
||||
snippet: str
|
||||
body_text: str
|
||||
event_at: datetime
|
||||
|
||||
|
||||
class HandoffConversationRead(BaseModel):
|
||||
id: str
|
||||
external_subject: str
|
||||
external_sender: str
|
||||
human_email: str
|
||||
handoff_reason: str
|
||||
status: str
|
||||
status_label: str
|
||||
current_owner: str
|
||||
linked_external: HandoffLinkedExternal
|
||||
messages: list[HandoffMessageRead]
|
||||
|
||||
|
||||
class OrgSecuritySettingsUpdate(BaseModel):
|
||||
require_strong_passwords: bool | None = None
|
||||
require_mfa_admins: bool | None = None
|
||||
|
|
@ -236,12 +342,21 @@ class OrgMemberRoleUpdate(BaseModel):
|
|||
|
||||
|
||||
class EmailCreate(BaseModel):
|
||||
recipients: list[EmailStr] = Field(min_length=1)
|
||||
recipients: list[EmailStr] = Field(default_factory=list)
|
||||
cc: list[EmailStr] = Field(default_factory=list)
|
||||
bcc: list[EmailStr] = Field(default_factory=list)
|
||||
subject: str = Field(default="", max_length=998, pattern=r"^[^\r\n]*$")
|
||||
body_text: str = ""
|
||||
body_html: str | None = None
|
||||
reply_to_message_id: str | None = None
|
||||
reply_all: bool = False
|
||||
thread_id: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def recipients_required_without_reply(self):
|
||||
if not self.reply_to_message_id and not self.recipients:
|
||||
raise ValueError("At least one recipient is required.")
|
||||
return self
|
||||
|
||||
|
||||
class EmailRead(EmailCreate):
|
||||
|
|
@ -278,6 +393,7 @@ class MessageSummary(BaseModel):
|
|||
is_starred: bool = False
|
||||
trashed_at: datetime | None = None
|
||||
attachments: list[AttachmentRead] = Field(default_factory=list)
|
||||
thread_id: str | None = None
|
||||
|
||||
|
||||
class MessageRead(MessageSummary):
|
||||
|
|
@ -287,6 +403,10 @@ class MessageRead(MessageSummary):
|
|||
body_text: str = ""
|
||||
body_html: str | None = None
|
||||
message_id: str | None = None
|
||||
in_reply_to: str | None = None
|
||||
references: list[str] = Field(default_factory=list)
|
||||
parent_message_id: str | None = None
|
||||
thread_relation: str | None = None
|
||||
|
||||
|
||||
class MessageReadState(BaseModel):
|
||||
|
|
@ -322,6 +442,34 @@ class MessagePage(BaseModel):
|
|||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class ThreadSummary(BaseModel):
|
||||
id: str
|
||||
mailbox: Literal["inbox", "sent", "trash"]
|
||||
subject: str
|
||||
snippet: str
|
||||
participants: list[MailAddress] = Field(default_factory=list)
|
||||
message_count: int
|
||||
unread_count: int = 0
|
||||
is_read: bool = True
|
||||
is_starred: bool = False
|
||||
has_attachments: bool = False
|
||||
latest_message_id: str
|
||||
latest_at: datetime
|
||||
senders: list[MailAddress] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ThreadPage(BaseModel):
|
||||
items: list[ThreadSummary]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class ThreadRead(BaseModel):
|
||||
thread_id: str
|
||||
subject: str
|
||||
message_count: int
|
||||
messages: list[MessageRead]
|
||||
|
||||
|
||||
class DomainCreate(BaseModel):
|
||||
domain_name: str = Field(min_length=3, max_length=253)
|
||||
account_id: UUID | None = None
|
||||
|
|
|
|||
117
backend/app/services/handoff_evaluator.py
Normal file
117
backend/app/services/handoff_evaluator.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import re
|
||||
|
||||
HANDOFF_LABELS = ["Important", "Finance", "Clients", "Projects", "Personal", "Support"]
|
||||
|
||||
EMAIL_TYPE_META = {
|
||||
"utility": {
|
||||
"title": "Utility",
|
||||
"patterns": r"account update|alert|notice|security|verification|password|service information|noreply|no-reply",
|
||||
},
|
||||
"transactional": {
|
||||
"title": "Transactional",
|
||||
"patterns": r"order|payment|invoice|receipt|billing|subscription|shipped|confirmation|refund|approval",
|
||||
},
|
||||
"promotional": {
|
||||
"title": "Promotional",
|
||||
"patterns": r"offer|deal|discount|promo|sale|limited time|save \d+%",
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"patterns": r"newsletter|campaign|marketing|outreach|webinar|subscribe|unsubscribe",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _email_text(email: dict) -> str:
|
||||
parts = [
|
||||
email.get("subject", ""),
|
||||
email.get("snippet", ""),
|
||||
email.get("body_text", ""),
|
||||
]
|
||||
return " ".join(parts).lower()
|
||||
|
||||
|
||||
def infer_labels(email: dict) -> list[str]:
|
||||
text = _email_text(email)
|
||||
matched: list[str] = []
|
||||
if re.search(r"urgent|important|asap|priority", text) or email.get("is_starred"):
|
||||
matched.append("Important")
|
||||
if re.search(r"invoice|bill|payment|finance|receipt", text):
|
||||
matched.append("Finance")
|
||||
if re.search(r"client|customer", text):
|
||||
matched.append("Clients")
|
||||
if re.search(r"project|proposal|contract", text):
|
||||
matched.append("Projects")
|
||||
if re.search(r"personal|family", text):
|
||||
matched.append("Personal")
|
||||
if re.search(r"support|help desk|ticket|assistance", text):
|
||||
matched.append("Support")
|
||||
return matched
|
||||
|
||||
|
||||
def infer_email_types(email: dict) -> list[str]:
|
||||
text = _email_text(email)
|
||||
matched: list[str] = []
|
||||
for key, meta in EMAIL_TYPE_META.items():
|
||||
if re.search(meta["patterns"], text, flags=re.IGNORECASE):
|
||||
matched.append(key)
|
||||
return matched
|
||||
|
||||
|
||||
def instruction_matches(instructions: str, email: dict) -> tuple[bool, str]:
|
||||
text = _email_text(email)
|
||||
instructions = (instructions or "").strip()
|
||||
if not instructions:
|
||||
return True, "No handoff instructions were set, so this step passes."
|
||||
|
||||
keywords = re.findall(r"[a-z]{4,}", instructions.lower())
|
||||
topic_hits = [word for word in keywords if word in text and len(word) > 4]
|
||||
if topic_hits:
|
||||
return True, f"Mentions topics related to your instructions ({', '.join(topic_hits[:3])})."
|
||||
|
||||
intent_patterns = [
|
||||
(r"refund|chargeback", "This email appears to involve a refund or payment issue."),
|
||||
(r"approval|authorize|sign off", "This email appears to require approval."),
|
||||
(r"human|speak with|talk to|call me", "The sender appears to be asking for a person."),
|
||||
(r"not confident|unsure|escalat", "Your instructions mention uncertain situations."),
|
||||
(r"urgent|asap|immediately", "The message appears urgent."),
|
||||
]
|
||||
for pattern, reason in intent_patterns:
|
||||
if re.search(pattern, instructions, flags=re.IGNORECASE) and re.search(pattern, text, flags=re.IGNORECASE):
|
||||
return True, reason
|
||||
|
||||
if re.search(r"refund|approval|human|speak with|person|payment", instructions, flags=re.IGNORECASE):
|
||||
if re.search(r"refund|approval|human|speak|person|payment|decision", text, flags=re.IGNORECASE):
|
||||
return True, "The email situation matches your handoff instructions."
|
||||
|
||||
return False, "This email does not clearly match your handoff instructions."
|
||||
|
||||
|
||||
def evaluate_handoff(
|
||||
*,
|
||||
email: dict,
|
||||
labels: list[str],
|
||||
email_types: list[str],
|
||||
instructions: str,
|
||||
) -> dict:
|
||||
inferred_labels = infer_labels(email)
|
||||
inferred_types = infer_email_types(email)
|
||||
|
||||
label_ok = not labels or any(label in inferred_labels for label in labels)
|
||||
matched_labels = [label for label in labels if label in inferred_labels] if labels else inferred_labels[:1]
|
||||
|
||||
type_ok = not email_types or any(item in inferred_types for item in email_types)
|
||||
matched_types = [item for item in email_types if item in inferred_types] if email_types else inferred_types[:1]
|
||||
|
||||
instruction_ok, instruction_reason = instruction_matches(instructions, email)
|
||||
would_handoff = label_ok and type_ok and instruction_ok
|
||||
|
||||
return {
|
||||
"label_matched": label_ok,
|
||||
"matched_labels": matched_labels,
|
||||
"type_matched": type_ok,
|
||||
"matched_types": matched_types,
|
||||
"instruction_matched": instruction_ok,
|
||||
"instruction_reason": instruction_reason,
|
||||
"would_handoff": would_handoff,
|
||||
}
|
||||
669
backend/app/services/human_handoff_service.py
Normal file
669
backend/app/services/human_handoff_service.py
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.handoff_evaluator import evaluate_handoff
|
||||
from app.services.mail_sender import deliver_email
|
||||
from app.services.message_ids import build_references_header, normalize_message_id, parse_message_id_list
|
||||
from app.services.thread_service import (
|
||||
attach_message_to_thread,
|
||||
message_event_at,
|
||||
threading_fields_from_parent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
|
||||
MESSAGE_SCOPE_HANDOFF = "HANDOFF"
|
||||
THREAD_TYPE_EXTERNAL = "EXTERNAL"
|
||||
THREAD_TYPE_HUMAN_HANDOFF = "HUMAN_HANDOFF"
|
||||
|
||||
HANDOFF_STATUS_ACTIVE = "ACTIVE"
|
||||
HANDOFF_STATUS_WAITING = "WAITING_FOR_HUMAN"
|
||||
HANDOFF_STATUS_HUMAN_REPLIED = "HUMAN_REPLIED"
|
||||
HANDOFF_STATUS_AGENT_REPLIED = "AGENT_REPLIED"
|
||||
|
||||
OWNER_AGENT = "AGENT"
|
||||
OWNER_HUMAN = "HUMAN"
|
||||
|
||||
LINK_EXTERNAL_TRIGGERED = "EXTERNAL_TRIGGERED_HANDOFF"
|
||||
LINK_EXTERNAL_RELAYED = "EXTERNAL_RELAYED_TO_HUMAN"
|
||||
LINK_HUMAN_TRIGGERED_EXTERNAL = "HUMAN_TRIGGERED_EXTERNAL_REPLY"
|
||||
LINK_EXTERNAL_UPDATE_RELAYED = "EXTERNAL_UPDATE_RELAYED_TO_HUMAN"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _snippet(text: str, limit: int = 240) -> str:
|
||||
return re.sub(r"\s+", " ", (text or "")).strip()[:limit]
|
||||
|
||||
|
||||
def _handoff_subject(external_subject: str) -> str:
|
||||
subject = (external_subject or "(No subject)").strip()
|
||||
if subject.lower().startswith("human handoff:"):
|
||||
return subject
|
||||
return f"Human Handoff: {subject}"
|
||||
|
||||
|
||||
def _build_handoff_body(
|
||||
*,
|
||||
external_sender: str,
|
||||
external_subject: str,
|
||||
external_body: str,
|
||||
handoff_reason: str,
|
||||
agent_email: str,
|
||||
) -> str:
|
||||
return (
|
||||
"Human Handoff\n\n"
|
||||
"A conversation needs your attention.\n\n"
|
||||
f"From\n{external_sender}\n\n"
|
||||
f"Subject\n{external_subject}\n\n"
|
||||
"Latest message\n\n"
|
||||
f"\"{external_body.strip()}\"\n\n"
|
||||
"Why this was handed off\n\n"
|
||||
f"{handoff_reason}\n\n"
|
||||
"────────────────────────────────────────\n\n"
|
||||
f"Reply to this email to respond to the original sender through {agent_email}.\n\n"
|
||||
"Your reply will be sent to the customer.\n\n"
|
||||
"────────────────────────────────────────\n\n"
|
||||
"Gentic Human Handoff"
|
||||
)
|
||||
|
||||
|
||||
async def create_empty_handoff_thread(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
subject: str,
|
||||
) -> ObjectId:
|
||||
now = _now()
|
||||
document = {
|
||||
"account_id": account_id,
|
||||
"thread_type": THREAD_TYPE_HUMAN_HANDOFF,
|
||||
"normalized_subject": subject,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_message_at": now,
|
||||
"latest_message_id": None,
|
||||
"message_count": 0,
|
||||
}
|
||||
result = await database.email_threads.insert_one(document)
|
||||
return result.inserted_id
|
||||
|
||||
|
||||
async def _record_message_link(
|
||||
database,
|
||||
*,
|
||||
handoff_id: ObjectId,
|
||||
external_message_id: ObjectId | None,
|
||||
handoff_message_id: ObjectId,
|
||||
relationship: str,
|
||||
) -> None:
|
||||
await database.handoff_message_links.insert_one(
|
||||
{
|
||||
"handoff_id": handoff_id,
|
||||
"external_message_id": external_message_id,
|
||||
"handoff_message_id": handoff_message_id,
|
||||
"relationship": relationship,
|
||||
"created_at": _now(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _latest_external_message(database, account_id: str, external_thread_id: ObjectId) -> dict | None:
|
||||
pipeline = [
|
||||
{
|
||||
"$match": {
|
||||
"account_id": account_id,
|
||||
"thread_id": external_thread_id,
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"deletion_state": {"$in": [None, ""]},
|
||||
}
|
||||
},
|
||||
{"$sort": {"event_at": -1, "_id": -1}},
|
||||
{"$limit": 1},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
|
||||
return rows[0]["message"] if rows else None
|
||||
|
||||
|
||||
async def _latest_handoff_message(database, account_id: str, handoff_thread_id: ObjectId) -> dict | None:
|
||||
pipeline = [
|
||||
{
|
||||
"$match": {
|
||||
"account_id": account_id,
|
||||
"thread_id": handoff_thread_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
},
|
||||
{"$sort": {"event_at": -1, "_id": -1}},
|
||||
{"$limit": 1},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
|
||||
return rows[0]["message"] if rows else None
|
||||
|
||||
|
||||
async def find_active_handoff_for_external_thread(
|
||||
database,
|
||||
account_id: str,
|
||||
external_thread_id: ObjectId,
|
||||
) -> dict | None:
|
||||
return await database.human_handoffs.find_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"external_thread_id": external_thread_id,
|
||||
"status": {"$in": [HANDOFF_STATUS_ACTIVE, HANDOFF_STATUS_WAITING, HANDOFF_STATUS_HUMAN_REPLIED, HANDOFF_STATUS_AGENT_REPLIED]},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def resolve_handoff_from_inbound(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
agent_email: str,
|
||||
sender_address: str,
|
||||
in_reply_to: str | None,
|
||||
references: list[str],
|
||||
) -> dict | None:
|
||||
sender = sender_address.lower()
|
||||
rules = await database.human_handoff_rules.find(
|
||||
{"account_id": account_id, "owner_email": sender, "active": True}
|
||||
).to_list(length=10)
|
||||
if not rules:
|
||||
return None
|
||||
|
||||
search_ids = [*([in_reply_to] if in_reply_to else []), *references]
|
||||
if not search_ids:
|
||||
return None
|
||||
|
||||
for internet_id in reversed(parse_message_id_list(" ".join(search_ids))):
|
||||
message = await database.messages.find_one(
|
||||
{
|
||||
"message_id": internet_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
)
|
||||
if message is None:
|
||||
continue
|
||||
entry = await database.mailbox_entries.find_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"message_id": message["_id"],
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
)
|
||||
if entry is None:
|
||||
continue
|
||||
handoff = await database.human_handoffs.find_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"handoff_thread_id": entry["thread_id"],
|
||||
"human_email": sender,
|
||||
"status": {"$ne": "CLOSED"},
|
||||
}
|
||||
)
|
||||
if handoff:
|
||||
return handoff
|
||||
return None
|
||||
|
||||
|
||||
async def store_handoff_message(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
agent_email: str,
|
||||
handoff_thread_id: ObjectId,
|
||||
direction: str,
|
||||
sender: dict,
|
||||
recipients: list[dict],
|
||||
subject: str,
|
||||
body_text: str,
|
||||
internet_message_id: str | None = None,
|
||||
in_reply_to: str | None = None,
|
||||
references: list[str] | None = None,
|
||||
handoff_id: ObjectId | None = None,
|
||||
link_relationship: str | None = None,
|
||||
external_message_id: ObjectId | None = None,
|
||||
event_at: datetime | None = None,
|
||||
) -> dict:
|
||||
now = event_at or _now()
|
||||
document = {
|
||||
"direction": direction,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
"account_id": account_id,
|
||||
"sender": sender,
|
||||
"to": recipients,
|
||||
"subject": subject,
|
||||
"snippet": _snippet(body_text),
|
||||
"body_text": body_text,
|
||||
"body_html": None,
|
||||
"attachments": [],
|
||||
"created_at": now,
|
||||
"sent_at": now if direction == "outbound" else None,
|
||||
"received_at": now if direction == "inbound" else None,
|
||||
"status": "received" if direction == "inbound" else "accepted",
|
||||
"message_id": normalize_message_id(internet_message_id),
|
||||
"in_reply_to": normalize_message_id(in_reply_to),
|
||||
"references": references or [],
|
||||
}
|
||||
result = await database.messages.insert_one(document)
|
||||
document["_id"] = result.inserted_id
|
||||
await attach_message_to_thread(
|
||||
database,
|
||||
account_id=account_id,
|
||||
thread_id=handoff_thread_id,
|
||||
message_id=result.inserted_id,
|
||||
subject=subject,
|
||||
event_at=now,
|
||||
)
|
||||
await database.mailbox_entries.insert_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"message_id": result.inserted_id,
|
||||
"thread_id": handoff_thread_id,
|
||||
"mailbox": "handoff",
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
"direction": direction,
|
||||
"sender": sender,
|
||||
"recipients": recipients,
|
||||
"subject": subject,
|
||||
"snippet": document["snippet"],
|
||||
"attachments": [],
|
||||
"status": document["status"],
|
||||
"is_read": direction == "outbound",
|
||||
"is_starred": False,
|
||||
"event_at": now,
|
||||
"created_at": now,
|
||||
"trashed_at": None,
|
||||
"deletion_state": None,
|
||||
}
|
||||
)
|
||||
if handoff_id and link_relationship:
|
||||
await _record_message_link(
|
||||
database,
|
||||
handoff_id=handoff_id,
|
||||
external_message_id=external_message_id,
|
||||
handoff_message_id=result.inserted_id,
|
||||
relationship=link_relationship,
|
||||
)
|
||||
return document
|
||||
|
||||
|
||||
async def send_handoff_to_human(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
agent_email: str,
|
||||
human_email: str,
|
||||
external_message: dict,
|
||||
external_thread_id: ObjectId,
|
||||
handoff_reason: str,
|
||||
domain,
|
||||
existing_handoff: dict | None = None,
|
||||
) -> dict:
|
||||
external_sender = external_message.get("sender", {})
|
||||
external_sender_text = external_sender.get("address") or "unknown sender"
|
||||
external_subject = external_message.get("subject", "")
|
||||
external_body = external_message.get("body_text") or external_message.get("snippet") or ""
|
||||
|
||||
if existing_handoff:
|
||||
handoff_id = existing_handoff["_id"]
|
||||
handoff_thread_id = existing_handoff["handoff_thread_id"]
|
||||
parent = await _latest_handoff_message(database, account_id, handoff_thread_id)
|
||||
threading = threading_fields_from_parent(parent) if parent else {}
|
||||
subject = _handoff_subject(external_subject)
|
||||
body = _build_handoff_body(
|
||||
external_sender=external_sender_text,
|
||||
external_subject=external_subject,
|
||||
external_body=external_body,
|
||||
handoff_reason="New message in the linked conversation.",
|
||||
agent_email=agent_email,
|
||||
)
|
||||
relationship = LINK_EXTERNAL_UPDATE_RELAYED
|
||||
else:
|
||||
handoff_thread_id = await create_empty_handoff_thread(
|
||||
database,
|
||||
account_id=account_id,
|
||||
subject=_handoff_subject(external_subject),
|
||||
)
|
||||
threading = {}
|
||||
subject = _handoff_subject(external_subject)
|
||||
body = _build_handoff_body(
|
||||
external_sender=external_sender_text,
|
||||
external_subject=external_subject,
|
||||
external_body=external_body,
|
||||
handoff_reason=handoff_reason,
|
||||
agent_email=agent_email,
|
||||
)
|
||||
handoff_doc = {
|
||||
"account_id": account_id,
|
||||
"agent_email": agent_email.lower(),
|
||||
"human_email": human_email.lower(),
|
||||
"external_thread_id": external_thread_id,
|
||||
"handoff_thread_id": handoff_thread_id,
|
||||
"external_subject": external_subject,
|
||||
"external_sender": external_sender_text,
|
||||
"handoff_reason": handoff_reason,
|
||||
"status": HANDOFF_STATUS_WAITING,
|
||||
"current_owner": OWNER_HUMAN,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"last_external_message_at": message_event_at(external_message),
|
||||
"last_human_message_at": None,
|
||||
}
|
||||
insert = await database.human_handoffs.insert_one(handoff_doc)
|
||||
handoff_id = insert.inserted_id
|
||||
relationship = LINK_EXTERNAL_TRIGGERED
|
||||
|
||||
internet_message_id = await deliver_email(
|
||||
sender=agent_email,
|
||||
recipients=[human_email],
|
||||
cc=[],
|
||||
bcc=[],
|
||||
subject=subject,
|
||||
body_text=body,
|
||||
body_html=None,
|
||||
domain=domain,
|
||||
in_reply_to=threading.get("in_reply_to"),
|
||||
references=threading.get("references"),
|
||||
)
|
||||
|
||||
handoff_message = await store_handoff_message(
|
||||
database,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
handoff_thread_id=handoff_thread_id,
|
||||
direction="outbound",
|
||||
sender={"name": "Gentic Agent", "address": agent_email},
|
||||
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
|
||||
subject=subject,
|
||||
body_text=body,
|
||||
internet_message_id=internet_message_id,
|
||||
in_reply_to=threading.get("in_reply_to"),
|
||||
references=threading.get("references"),
|
||||
handoff_id=handoff_id,
|
||||
link_relationship=relationship,
|
||||
external_message_id=external_message["_id"],
|
||||
)
|
||||
|
||||
await database.human_handoffs.update_one(
|
||||
{"_id": handoff_id},
|
||||
{
|
||||
"$set": {
|
||||
"status": HANDOFF_STATUS_WAITING,
|
||||
"current_owner": OWNER_HUMAN,
|
||||
"updated_at": _now(),
|
||||
"last_external_message_at": message_event_at(external_message),
|
||||
"latest_handoff_message_id": handoff_message["_id"],
|
||||
}
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"handoff_sent handoff_id=%s external_thread=%s handoff_thread=%s",
|
||||
handoff_id,
|
||||
external_thread_id,
|
||||
handoff_thread_id,
|
||||
)
|
||||
return await database.human_handoffs.find_one({"_id": handoff_id})
|
||||
|
||||
|
||||
async def _handoff_rule_for_human(database, account_id: str, human_email: str) -> dict | None:
|
||||
return await database.human_handoff_rules.find_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"owner_email": human_email.lower(),
|
||||
"active": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def process_human_reply(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
agent_email: str,
|
||||
handoff: dict,
|
||||
inbound_parsed: dict,
|
||||
inbound_body: str,
|
||||
domain,
|
||||
) -> None:
|
||||
handoff_thread_id = handoff["handoff_thread_id"]
|
||||
external_thread_id = handoff["external_thread_id"]
|
||||
human_email = handoff["human_email"]
|
||||
rule = await _handoff_rule_for_human(database, account_id, human_email)
|
||||
allow_replies = rule.get("allow_replies", True) if rule else True
|
||||
|
||||
human_message = await store_handoff_message(
|
||||
database,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
handoff_thread_id=handoff_thread_id,
|
||||
direction="inbound",
|
||||
sender={"name": human_email.split("@")[0], "address": human_email},
|
||||
recipients=[{"name": "Gentic Agent", "address": agent_email}],
|
||||
subject=inbound_parsed.get("subject", ""),
|
||||
body_text=inbound_body,
|
||||
internet_message_id=inbound_parsed.get("message_id"),
|
||||
in_reply_to=inbound_parsed.get("in_reply_to"),
|
||||
references=inbound_parsed.get("references", []),
|
||||
handoff_id=handoff["_id"],
|
||||
link_relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
|
||||
)
|
||||
|
||||
now = _now()
|
||||
if not allow_replies:
|
||||
await database.human_handoffs.update_one(
|
||||
{"_id": handoff["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"status": HANDOFF_STATUS_HUMAN_REPLIED,
|
||||
"current_owner": OWNER_AGENT,
|
||||
"updated_at": now,
|
||||
"last_human_message_at": now,
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
external_parent = await _latest_external_message(database, account_id, external_thread_id)
|
||||
if external_parent is None:
|
||||
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
|
||||
return
|
||||
|
||||
threading = threading_fields_from_parent(external_parent)
|
||||
external_subject = handoff.get("external_subject") or external_parent.get("subject", "")
|
||||
if not re.match(r"^re:\s*", external_subject, flags=re.IGNORECASE):
|
||||
external_subject = f"Re: {external_subject}"
|
||||
|
||||
external_recipient = handoff.get("external_sender") or external_parent.get("sender", {}).get("address")
|
||||
if not external_recipient:
|
||||
return
|
||||
|
||||
customer_body = inbound_body.strip()
|
||||
internet_message_id = await deliver_email(
|
||||
sender=agent_email,
|
||||
recipients=[external_recipient],
|
||||
cc=[],
|
||||
bcc=[],
|
||||
subject=external_subject,
|
||||
body_text=customer_body,
|
||||
body_html=None,
|
||||
domain=domain,
|
||||
in_reply_to=threading.get("in_reply_to"),
|
||||
references=threading.get("references"),
|
||||
)
|
||||
|
||||
external_doc = {
|
||||
"direction": "outbound",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"account_id": account_id,
|
||||
"sender": {"name": "Gentic Agent", "address": agent_email},
|
||||
"to": [{"name": external_recipient.split("@")[0], "address": external_recipient}],
|
||||
"subject": external_subject,
|
||||
"snippet": _snippet(customer_body),
|
||||
"body_text": customer_body,
|
||||
"body_html": None,
|
||||
"attachments": [],
|
||||
"created_at": now,
|
||||
"sent_at": now,
|
||||
"status": "accepted",
|
||||
"message_id": normalize_message_id(internet_message_id),
|
||||
"in_reply_to": threading.get("in_reply_to"),
|
||||
"references": threading.get("references", []),
|
||||
"parent_message_id": external_parent["_id"],
|
||||
"thread_relation": "reply",
|
||||
"thread_resolution_method": "human_handoff_bridge",
|
||||
}
|
||||
ext_result = await database.messages.insert_one(external_doc)
|
||||
external_doc["_id"] = ext_result.inserted_id
|
||||
await attach_message_to_thread(
|
||||
database,
|
||||
account_id=account_id,
|
||||
thread_id=external_thread_id,
|
||||
message_id=ext_result.inserted_id,
|
||||
subject=external_subject,
|
||||
event_at=now,
|
||||
)
|
||||
await database.mailbox_entries.insert_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"message_id": ext_result.inserted_id,
|
||||
"thread_id": external_thread_id,
|
||||
"mailbox": "sent",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"sender": external_doc["sender"],
|
||||
"recipients": external_doc["to"],
|
||||
"subject": external_subject,
|
||||
"snippet": external_doc["snippet"],
|
||||
"attachments": [],
|
||||
"status": "accepted",
|
||||
"is_read": True,
|
||||
"is_starred": False,
|
||||
"event_at": now,
|
||||
"created_at": now,
|
||||
"trashed_at": None,
|
||||
"deletion_state": None,
|
||||
}
|
||||
)
|
||||
|
||||
relay_body = f"Sent to customer:\n\n\"{customer_body}\""
|
||||
relay_subject = f"Re: {_handoff_subject(handoff.get('external_subject', ''))}"
|
||||
relay_threading = threading_fields_from_parent(human_message)
|
||||
relay_internet_id = await deliver_email(
|
||||
sender=agent_email,
|
||||
recipients=[human_email],
|
||||
cc=[],
|
||||
bcc=[],
|
||||
subject=relay_subject,
|
||||
body_text=relay_body,
|
||||
body_html=None,
|
||||
domain=domain,
|
||||
in_reply_to=relay_threading.get("in_reply_to"),
|
||||
references=relay_threading.get("references"),
|
||||
)
|
||||
await store_handoff_message(
|
||||
database,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
handoff_thread_id=handoff_thread_id,
|
||||
direction="outbound",
|
||||
sender={"name": "Gentic Agent", "address": agent_email},
|
||||
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
|
||||
subject=relay_subject,
|
||||
body_text=relay_body,
|
||||
internet_message_id=relay_internet_id,
|
||||
in_reply_to=relay_threading.get("in_reply_to"),
|
||||
references=relay_threading.get("references"),
|
||||
handoff_id=handoff["_id"],
|
||||
link_relationship=LINK_EXTERNAL_RELAYED,
|
||||
external_message_id=ext_result.inserted_id,
|
||||
)
|
||||
|
||||
await database.human_handoffs.update_one(
|
||||
{"_id": handoff["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"status": HANDOFF_STATUS_AGENT_REPLIED,
|
||||
"current_owner": OWNER_AGENT,
|
||||
"updated_at": now,
|
||||
"last_human_message_at": now,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def maybe_trigger_handoff(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
agent_email: str,
|
||||
external_message: dict,
|
||||
external_thread_id: ObjectId,
|
||||
domain,
|
||||
) -> None:
|
||||
rules = await database.human_handoff_rules.find(
|
||||
{"account_id": account_id, "active": True}
|
||||
).to_list(length=20)
|
||||
if not rules:
|
||||
return
|
||||
|
||||
body_text = external_message.get("body_text") or external_message.get("snippet") or ""
|
||||
email_payload = {
|
||||
"subject": external_message.get("subject", ""),
|
||||
"snippet": external_message.get("snippet", ""),
|
||||
"body_text": body_text,
|
||||
"is_starred": False,
|
||||
}
|
||||
|
||||
for rule in rules:
|
||||
evaluation = evaluate_handoff(
|
||||
email=email_payload,
|
||||
labels=rule.get("labels", []),
|
||||
email_types=rule.get("email_types", []),
|
||||
instructions=rule.get("instructions", ""),
|
||||
)
|
||||
if not evaluation["would_handoff"]:
|
||||
continue
|
||||
if not rule.get("allow_replies", True):
|
||||
continue
|
||||
|
||||
existing = await find_active_handoff_for_external_thread(
|
||||
database, account_id, external_thread_id
|
||||
)
|
||||
reason = evaluation.get("instruction_reason") or rule.get("instructions") or "Matched handoff rules."
|
||||
await send_handoff_to_human(
|
||||
database,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
human_email=rule["owner_email"],
|
||||
external_message=external_message,
|
||||
external_thread_id=external_thread_id,
|
||||
handoff_reason=reason,
|
||||
domain=domain,
|
||||
existing_handoff=existing,
|
||||
)
|
||||
return
|
||||
|
|
@ -9,8 +9,18 @@ from sqlalchemy import func, select
|
|||
from app.config import get_settings
|
||||
from app.database import SessionFactory, mongo_db
|
||||
from app.models import Account
|
||||
from app.services.domain_provisioning import get_domain_by_name
|
||||
from app.services.human_handoff_service import (
|
||||
MESSAGE_SCOPE_EXTERNAL,
|
||||
MESSAGE_SCOPE_HANDOFF,
|
||||
process_human_reply,
|
||||
resolve_handoff_from_inbound,
|
||||
maybe_trigger_handoff,
|
||||
)
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
|
||||
from app.services.thread_resolver import participants_from_parsed, resolve_thread
|
||||
from app.services.thread_service import ensure_thread_for_message, message_event_at
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -69,9 +79,59 @@ class InboundHandler:
|
|||
body_html = parsed.pop("body_html")
|
||||
stored = await get_raw_mime_storage().put(raw, delivery_key)
|
||||
now = datetime.now(timezone.utc)
|
||||
sender_address = (parsed.get("sender") or {}).get("address", "").lower()
|
||||
|
||||
for recipient in recipients:
|
||||
account_id = envelope.account_ids[recipient]
|
||||
agent_email = recipient
|
||||
|
||||
handoff = await resolve_handoff_from_inbound(
|
||||
mongo_db,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
sender_address=sender_address,
|
||||
in_reply_to=parsed.get("in_reply_to"),
|
||||
references=parsed.get("references", []),
|
||||
)
|
||||
if handoff:
|
||||
async with SessionFactory() as database_session:
|
||||
domain = await get_domain_by_name(
|
||||
database_session,
|
||||
agent_email.split("@", 1)[1],
|
||||
)
|
||||
if domain is None:
|
||||
logger.warning("handoff_reply_no_domain account=%s", account_id)
|
||||
continue
|
||||
message = {
|
||||
**parsed,
|
||||
"body_text": body_text,
|
||||
"body_html": body_html,
|
||||
"direction": "inbound",
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
"delivery_key": delivery_key,
|
||||
"received_at": now,
|
||||
"created_at": now,
|
||||
}
|
||||
await process_human_reply(
|
||||
mongo_db,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
handoff=handoff,
|
||||
inbound_parsed=message,
|
||||
inbound_body=body_text,
|
||||
domain=domain,
|
||||
)
|
||||
logger.info(
|
||||
"handoff_human_reply handoff_id=%s account=%s",
|
||||
handoff["_id"],
|
||||
account_id,
|
||||
)
|
||||
return f"250 2.0.0 Handoff reply accepted as {delivery_key}"
|
||||
|
||||
message = {
|
||||
**parsed,
|
||||
"direction": "inbound",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"envelope_sender": envelope.mail_from.lower(),
|
||||
"envelope_recipients": recipients,
|
||||
"delivery_key": delivery_key,
|
||||
|
|
@ -92,14 +152,54 @@ class InboundHandler:
|
|||
{"$setOnInsert": message},
|
||||
upsert=True,
|
||||
)
|
||||
persisted = await mongo_db.messages.find_one(
|
||||
{"delivery_key": delivery_key},
|
||||
{"_id": 1},
|
||||
)
|
||||
persisted = await mongo_db.messages.find_one({"delivery_key": delivery_key})
|
||||
if persisted is None:
|
||||
raise RuntimeError("Inbound message was not persisted.")
|
||||
|
||||
if body_text:
|
||||
await mongo_db.messages.update_one(
|
||||
{"_id": persisted["_id"]},
|
||||
{"$set": {"body_text": body_text, "body_html": body_html}},
|
||||
)
|
||||
persisted["body_text"] = body_text
|
||||
persisted["body_html"] = body_html
|
||||
|
||||
participants = participants_from_parsed(parsed)
|
||||
for recipient in recipients:
|
||||
account_id = envelope.account_ids[recipient]
|
||||
agent_email = recipient
|
||||
resolution = await resolve_thread(
|
||||
mongo_db,
|
||||
account_id=account_id,
|
||||
in_reply_to=parsed.get("in_reply_to"),
|
||||
references=parsed.get("references", []),
|
||||
subject=parsed.get("subject", ""),
|
||||
participants=participants,
|
||||
)
|
||||
thread_id = await ensure_thread_for_message(
|
||||
mongo_db,
|
||||
account_id=account_id,
|
||||
message=persisted,
|
||||
resolution=resolution,
|
||||
)
|
||||
await mongo_db.email_threads.update_one(
|
||||
{"_id": thread_id},
|
||||
{"$set": {"thread_type": "EXTERNAL"}},
|
||||
)
|
||||
await mongo_db.messages.update_one(
|
||||
{"_id": persisted["_id"]},
|
||||
{
|
||||
"$set": {
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"in_reply_to": parsed.get("in_reply_to"),
|
||||
"references": parsed.get("references", []),
|
||||
"raw_headers": parsed.get("raw_headers", {}),
|
||||
"parent_message_id": resolution.parent_message_id,
|
||||
"thread_relation": resolution.thread_relation,
|
||||
"thread_resolution_method": resolution.thread_resolution_method,
|
||||
}
|
||||
},
|
||||
)
|
||||
await mongo_db.mailbox_entries.update_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
|
|
@ -111,6 +211,7 @@ class InboundHandler:
|
|||
"account_id": account_id,
|
||||
"message_id": persisted["_id"],
|
||||
"mailbox": "inbox",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "inbound",
|
||||
"sender": parsed["sender"],
|
||||
"recipients": parsed["to"],
|
||||
|
|
@ -120,14 +221,34 @@ class InboundHandler:
|
|||
"status": "received",
|
||||
"is_read": False,
|
||||
"is_starred": False,
|
||||
"event_at": now,
|
||||
"event_at": message_event_at(persisted),
|
||||
"created_at": now,
|
||||
"trashed_at": None,
|
||||
"deletion_state": None,
|
||||
}
|
||||
},
|
||||
"$set": {"thread_id": thread_id},
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
async with SessionFactory() as database_session:
|
||||
domain = await get_domain_by_name(
|
||||
database_session,
|
||||
agent_email.split("@", 1)[1],
|
||||
)
|
||||
if domain is not None:
|
||||
await maybe_trigger_handoff(
|
||||
mongo_db,
|
||||
account_id=account_id,
|
||||
agent_email=agent_email,
|
||||
external_message=persisted,
|
||||
external_thread_id=thread_id,
|
||||
domain=domain,
|
||||
)
|
||||
logger.info(
|
||||
"message_received message_id=%s thread_id=%s scope=EXTERNAL",
|
||||
persisted["_id"],
|
||||
thread_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Temporary failure accepting inbound message")
|
||||
return "451 4.3.0 Temporary server failure; please retry"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ def build_message(
|
|||
body_text: str,
|
||||
body_html: str | None,
|
||||
reply_to: str | None = None,
|
||||
in_reply_to: str | None = None,
|
||||
references: list[str] | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> EmailMessage:
|
||||
message = EmailMessage(policy=SMTP_POLICY)
|
||||
message["From"] = sender
|
||||
|
|
@ -30,7 +33,12 @@ def build_message(
|
|||
message["Reply-To"] = reply_to
|
||||
message["Subject"] = subject
|
||||
message["Date"] = email.utils.formatdate(localtime=True)
|
||||
message["Message-ID"] = email.utils.make_msgid(domain=sender.split("@")[-1])
|
||||
generated_id = message_id or email.utils.make_msgid(domain=sender.split("@")[-1])
|
||||
message["Message-ID"] = generated_id
|
||||
if in_reply_to:
|
||||
message["In-Reply-To"] = in_reply_to
|
||||
if references:
|
||||
message["References"] = " ".join(references)
|
||||
message.set_content(body_text)
|
||||
if body_html:
|
||||
message.add_alternative(body_html, subtype="html")
|
||||
|
|
@ -95,7 +103,10 @@ async def deliver_email(
|
|||
body_html: str | None,
|
||||
domain: MailDomain,
|
||||
reply_to: str | None = None,
|
||||
) -> None:
|
||||
in_reply_to: str | None = None,
|
||||
references: list[str] | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> str:
|
||||
message = build_message(
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
|
|
@ -104,6 +115,9 @@ async def deliver_email(
|
|||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
reply_to=reply_to,
|
||||
in_reply_to=in_reply_to,
|
||||
references=references,
|
||||
message_id=message_id,
|
||||
)
|
||||
signed = sign_message(message, domain)
|
||||
await send_via_smtp(
|
||||
|
|
@ -111,3 +125,4 @@ async def deliver_email(
|
|||
sender=sender,
|
||||
recipients=[*recipients, *cc, *bcc],
|
||||
)
|
||||
return str(message["Message-ID"])
|
||||
|
|
|
|||
18
backend/app/services/mailbox_scope.py
Normal file
18
backend/app/services/mailbox_scope.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
EXTERNAL_SCOPE_FILTER = {
|
||||
"$or": [
|
||||
{"message_scope": "EXTERNAL"},
|
||||
{"message_scope": {"$exists": False}},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def apply_external_scope(entry_match: dict) -> dict:
|
||||
if "$and" in entry_match:
|
||||
entry_match["$and"].append(EXTERNAL_SCOPE_FILTER)
|
||||
return entry_match
|
||||
if "$or" in entry_match:
|
||||
existing = entry_match.pop("$or")
|
||||
entry_match["$and"] = [{"$or": existing}, EXTERNAL_SCOPE_FILTER]
|
||||
return entry_match
|
||||
entry_match.update(EXTERNAL_SCOPE_FILTER)
|
||||
return entry_match
|
||||
51
backend/app/services/message_ids.py
Normal file
51
backend/app/services/message_ids.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import re
|
||||
|
||||
_MESSAGE_ID_PATTERN = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def normalize_message_id(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
if not cleaned.startswith("<"):
|
||||
cleaned = f"<{cleaned.strip('<>')}>"
|
||||
return cleaned.lower()
|
||||
|
||||
|
||||
def parse_message_id_list(header_value: str | None) -> list[str]:
|
||||
if not header_value:
|
||||
return []
|
||||
folded = re.sub(r"\s+", " ", header_value.strip())
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for match in _MESSAGE_ID_PATTERN.findall(folded):
|
||||
normalized = normalize_message_id(match)
|
||||
if normalized and normalized not in seen:
|
||||
seen.add(normalized)
|
||||
ordered.append(normalized)
|
||||
if not ordered:
|
||||
single = normalize_message_id(folded)
|
||||
if single:
|
||||
ordered.append(single)
|
||||
return ordered
|
||||
|
||||
|
||||
def first_message_id(header_value: str | None) -> str | None:
|
||||
values = parse_message_id_list(header_value)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def build_references_header(references: list[str], parent_message_id: str) -> str:
|
||||
chain: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in references:
|
||||
normalized = normalize_message_id(value)
|
||||
if normalized and normalized not in seen:
|
||||
seen.add(normalized)
|
||||
chain.append(normalized)
|
||||
parent = normalize_message_id(parent_message_id)
|
||||
if parent and parent not in seen:
|
||||
chain.append(parent)
|
||||
return " ".join(chain)
|
||||
|
|
@ -5,6 +5,8 @@ from email.parser import BytesParser
|
|||
from email.utils import getaddresses, parsedate_to_datetime
|
||||
from html import unescape
|
||||
|
||||
from app.services.message_ids import first_message_id, parse_message_id_list
|
||||
|
||||
|
||||
def _addresses(values: list[str]) -> list[dict[str, str]]:
|
||||
return [
|
||||
|
|
@ -75,8 +77,25 @@ def parse_mime(raw: bytes) -> dict:
|
|||
pass
|
||||
body_text = "\n".join(text_parts).strip()
|
||||
body_html = "\n".join(html_parts).strip() or None
|
||||
internet_message_id = first_message_id(message.get("Message-ID"))
|
||||
in_reply_to = first_message_id(message.get("In-Reply-To"))
|
||||
references = parse_message_id_list(message.get("References"))
|
||||
raw_headers = {
|
||||
"message_id": message.get("Message-ID"),
|
||||
"in_reply_to": message.get("In-Reply-To"),
|
||||
"references": message.get("References"),
|
||||
"subject": message.get("Subject"),
|
||||
"date": message.get("Date"),
|
||||
"from": message.get("From"),
|
||||
"to": message.get("To"),
|
||||
"cc": message.get("Cc"),
|
||||
"reply_to": message.get("Reply-To"),
|
||||
}
|
||||
return {
|
||||
"message_id": (message.get("Message-ID") or "").strip() or None,
|
||||
"message_id": internet_message_id,
|
||||
"in_reply_to": in_reply_to,
|
||||
"references": references,
|
||||
"raw_headers": raw_headers,
|
||||
"sender": from_value,
|
||||
"to": _addresses(message.get_all("to", [])),
|
||||
"cc": _addresses(message.get_all("cc", [])),
|
||||
|
|
|
|||
49
backend/app/services/reply_recipients.py
Normal file
49
backend/app/services/reply_recipients.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from app.schemas import MailAddress
|
||||
|
||||
|
||||
def _addresses(items: list[dict] | list[MailAddress] | None) -> list[str]:
|
||||
if not items:
|
||||
return []
|
||||
values: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
address = item.get("address", "")
|
||||
else:
|
||||
address = item.address
|
||||
if address:
|
||||
values.append(address.lower())
|
||||
return values
|
||||
|
||||
|
||||
def reply_recipients(
|
||||
*,
|
||||
message: dict,
|
||||
viewer_email: str,
|
||||
reply_all: bool = False,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
viewer = viewer_email.lower()
|
||||
own_addresses = {viewer}
|
||||
|
||||
reply_to = _addresses(message.get("reply_to"))
|
||||
sender = _addresses([message.get("sender", {})])
|
||||
to = _addresses(message.get("to") or message.get("recipients"))
|
||||
cc = _addresses(message.get("cc"))
|
||||
|
||||
primary = reply_to or sender
|
||||
primary = [address for address in primary if address not in own_addresses]
|
||||
|
||||
if not reply_all:
|
||||
return primary, []
|
||||
|
||||
recipients = []
|
||||
for address in [*primary, *to, *cc]:
|
||||
if address in own_addresses or address in recipients:
|
||||
continue
|
||||
recipients.append(address)
|
||||
|
||||
if not recipients and primary:
|
||||
recipients = primary
|
||||
|
||||
to_out = recipients[:1] if recipients else []
|
||||
cc_out = recipients[1:] if len(recipients) > 1 else []
|
||||
return to_out, cc_out
|
||||
16
backend/app/services/subject_normalize.py
Normal file
16
backend/app/services/subject_normalize.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import re
|
||||
|
||||
_PREFIX_PATTERN = re.compile(
|
||||
r"^(?:(?:re|fw|fwd)\s*(?:\[\d+\])?\s*:\s*)+",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def normalize_subject(subject: str | None) -> str:
|
||||
value = (subject or "").strip()
|
||||
while True:
|
||||
next_value = _PREFIX_PATTERN.sub("", value, count=1).strip()
|
||||
if next_value == value:
|
||||
break
|
||||
value = next_value
|
||||
return re.sub(r"\s+", " ", value).strip().lower()
|
||||
252
backend/app/services/thread_resolver.py
Normal file
252
backend/app/services/thread_resolver.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.message_ids import (
|
||||
first_message_id,
|
||||
normalize_message_id,
|
||||
parse_message_id_list,
|
||||
)
|
||||
from app.services.subject_normalize import normalize_subject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_WINDOW = timedelta(days=7)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThreadResolution:
|
||||
thread_id: ObjectId | None
|
||||
parent_message_id: ObjectId | None
|
||||
thread_relation: str
|
||||
thread_resolution_method: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownMessage:
|
||||
id: ObjectId
|
||||
thread_id: ObjectId
|
||||
internet_message_id: str
|
||||
account_id: str
|
||||
sent_at: datetime | None
|
||||
received_at: datetime | None
|
||||
created_at: datetime
|
||||
participants: frozenset[str]
|
||||
normalized_subject: str
|
||||
|
||||
|
||||
def _message_timestamp(message: KnownMessage) -> datetime:
|
||||
return message.sent_at or message.received_at or message.created_at
|
||||
|
||||
|
||||
def resolve_thread_from_known(
|
||||
*,
|
||||
account_id: str,
|
||||
in_reply_to: str | None,
|
||||
references: list[str],
|
||||
subject: str,
|
||||
participants: set[str],
|
||||
known_by_internet_id: dict[str, KnownMessage],
|
||||
known_threads: dict[ObjectId, list[KnownMessage]] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> ThreadResolution:
|
||||
scoped = {
|
||||
key: value
|
||||
for key, value in known_by_internet_id.items()
|
||||
if value.account_id == account_id
|
||||
}
|
||||
in_reply = normalize_message_id(in_reply_to)
|
||||
if in_reply and in_reply in scoped:
|
||||
parent = scoped[in_reply]
|
||||
return ThreadResolution(
|
||||
thread_id=parent.thread_id,
|
||||
parent_message_id=parent.id,
|
||||
thread_relation="reply",
|
||||
thread_resolution_method="in_reply_to",
|
||||
)
|
||||
|
||||
reference_ids = [
|
||||
normalize_message_id(value)
|
||||
for value in references
|
||||
if normalize_message_id(value)
|
||||
]
|
||||
for reference in reversed(reference_ids):
|
||||
if reference in scoped:
|
||||
parent = scoped[reference]
|
||||
return ThreadResolution(
|
||||
thread_id=parent.thread_id,
|
||||
parent_message_id=parent.id,
|
||||
thread_relation="reply",
|
||||
thread_resolution_method="references",
|
||||
)
|
||||
|
||||
fallback = _conservative_fallback(
|
||||
account_id=account_id,
|
||||
subject=subject,
|
||||
participants=participants,
|
||||
known_by_internet_id=scoped,
|
||||
known_threads=known_threads or {},
|
||||
now=now or datetime.now(timezone.utc),
|
||||
)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
|
||||
return ThreadResolution(
|
||||
thread_id=None,
|
||||
parent_message_id=None,
|
||||
thread_relation="new_thread",
|
||||
thread_resolution_method="new_thread",
|
||||
)
|
||||
|
||||
|
||||
def _conservative_fallback(
|
||||
*,
|
||||
account_id: str,
|
||||
subject: str,
|
||||
participants: set[str],
|
||||
known_by_internet_id: dict[str, KnownMessage],
|
||||
known_threads: dict[ObjectId, list[KnownMessage]],
|
||||
now: datetime,
|
||||
) -> ThreadResolution | None:
|
||||
normalized = normalize_subject(subject)
|
||||
if not normalized or not participants:
|
||||
return None
|
||||
|
||||
candidates: dict[ObjectId, KnownMessage] = {}
|
||||
for message in known_by_internet_id.values():
|
||||
if message.normalized_subject != normalized:
|
||||
continue
|
||||
if not participants.intersection(message.participants):
|
||||
continue
|
||||
timestamp = _message_timestamp(message)
|
||||
if timestamp < now - FALLBACK_WINDOW:
|
||||
continue
|
||||
candidates[message.thread_id] = message
|
||||
|
||||
if len(candidates) != 1:
|
||||
return None
|
||||
|
||||
parent = next(iter(candidates.values()))
|
||||
return ThreadResolution(
|
||||
thread_id=parent.thread_id,
|
||||
parent_message_id=parent.id,
|
||||
thread_relation="unknown",
|
||||
thread_resolution_method="fallback",
|
||||
)
|
||||
|
||||
|
||||
def participants_from_parsed(parsed: dict) -> set[str]:
|
||||
addresses: set[str] = set()
|
||||
for key in ("sender",):
|
||||
sender = parsed.get(key)
|
||||
if isinstance(sender, dict) and sender.get("address"):
|
||||
addresses.add(sender["address"].lower())
|
||||
for key in ("to", "cc", "reply_to"):
|
||||
for item in parsed.get(key, []) or []:
|
||||
if isinstance(item, dict) and item.get("address"):
|
||||
addresses.add(item["address"].lower())
|
||||
return addresses
|
||||
|
||||
|
||||
async def lookup_known_messages(
|
||||
database,
|
||||
account_id: str,
|
||||
internet_ids: list[str],
|
||||
) -> dict[str, KnownMessage]:
|
||||
normalized_ids = [
|
||||
value
|
||||
for value in {normalize_message_id(item) for item in internet_ids}
|
||||
if value
|
||||
]
|
||||
if not normalized_ids:
|
||||
return {}
|
||||
|
||||
pipeline = [
|
||||
{
|
||||
"$match": {
|
||||
"message_id": {"$in": normalized_ids},
|
||||
"$or": [
|
||||
{"message_scope": "EXTERNAL"},
|
||||
{"message_scope": {"$exists": False}},
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "mailbox_entries",
|
||||
"let": {"message_id": "$_id"},
|
||||
"pipeline": [
|
||||
{
|
||||
"$match": {
|
||||
"$expr": {
|
||||
"$and": [
|
||||
{"$eq": ["$message_id", "$$message_id"]},
|
||||
{"$eq": ["$account_id", account_id]},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{"$limit": 1},
|
||||
],
|
||||
"as": "entry",
|
||||
}
|
||||
},
|
||||
{"$match": {"entry.0": {"$exists": True}}},
|
||||
]
|
||||
known: dict[str, KnownMessage] = {}
|
||||
cursor = database.messages.aggregate(pipeline)
|
||||
async for document in cursor:
|
||||
internet_id = normalize_message_id(document.get("message_id"))
|
||||
if not internet_id:
|
||||
continue
|
||||
entry = document["entry"][0]
|
||||
thread_id = entry.get("thread_id") or document.get("thread_id")
|
||||
if thread_id is None:
|
||||
continue
|
||||
participants = participants_from_parsed(document)
|
||||
known[internet_id] = KnownMessage(
|
||||
id=document["_id"],
|
||||
thread_id=thread_id,
|
||||
internet_message_id=internet_id,
|
||||
account_id=account_id,
|
||||
sent_at=document.get("sent_at"),
|
||||
received_at=document.get("received_at"),
|
||||
created_at=document.get("created_at"),
|
||||
participants=frozenset(participants),
|
||||
normalized_subject=normalize_subject(document.get("subject")),
|
||||
)
|
||||
return known
|
||||
|
||||
|
||||
async def resolve_thread(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
in_reply_to: str | None,
|
||||
references: list[str],
|
||||
subject: str,
|
||||
participants: set[str],
|
||||
) -> ThreadResolution:
|
||||
search_ids = [
|
||||
*([in_reply_to] if in_reply_to else []),
|
||||
*references,
|
||||
]
|
||||
known = await lookup_known_messages(database, account_id, search_ids)
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id=account_id,
|
||||
in_reply_to=in_reply_to,
|
||||
references=references,
|
||||
subject=subject,
|
||||
participants=participants,
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
logger.info(
|
||||
"thread_resolved account_id=%s method=%s relation=%s parent_found=%s",
|
||||
account_id,
|
||||
resolution.thread_resolution_method,
|
||||
resolution.thread_relation,
|
||||
resolution.parent_message_id is not None,
|
||||
)
|
||||
return resolution
|
||||
137
backend/app/services/thread_service.py
Normal file
137
backend/app/services/thread_service.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.message_ids import build_references_header, normalize_message_id
|
||||
from app.services.subject_normalize import normalize_subject
|
||||
from app.services.thread_resolver import ThreadResolution
|
||||
|
||||
|
||||
def message_event_at(message: dict) -> datetime:
|
||||
return (
|
||||
message.get("received_at")
|
||||
or message.get("sent_at")
|
||||
or message.get("created_at")
|
||||
or datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
async def create_thread(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
subject: str,
|
||||
message_id: ObjectId,
|
||||
event_at: datetime,
|
||||
thread_type: str = "EXTERNAL",
|
||||
) -> ObjectId:
|
||||
now = datetime.now(timezone.utc)
|
||||
document = {
|
||||
"account_id": account_id,
|
||||
"thread_type": thread_type,
|
||||
"normalized_subject": normalize_subject(subject),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_message_at": event_at,
|
||||
"latest_message_id": message_id,
|
||||
"message_count": 1,
|
||||
}
|
||||
result = await database.email_threads.insert_one(document)
|
||||
return result.inserted_id
|
||||
|
||||
|
||||
async def message_already_in_thread(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
thread_id: ObjectId,
|
||||
message_id: ObjectId,
|
||||
) -> bool:
|
||||
existing = await database.mailbox_entries.find_one(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"thread_id": thread_id,
|
||||
"message_id": message_id,
|
||||
},
|
||||
{"_id": 1},
|
||||
)
|
||||
return existing is not None
|
||||
|
||||
|
||||
async def attach_message_to_thread(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
thread_id: ObjectId,
|
||||
message_id: ObjectId,
|
||||
subject: str,
|
||||
event_at: datetime,
|
||||
) -> None:
|
||||
if await message_already_in_thread(
|
||||
database,
|
||||
account_id=account_id,
|
||||
thread_id=thread_id,
|
||||
message_id=message_id,
|
||||
):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
await database.email_threads.update_one(
|
||||
{"_id": thread_id, "account_id": account_id},
|
||||
{
|
||||
"$set": {
|
||||
"updated_at": now,
|
||||
"last_message_at": event_at,
|
||||
"latest_message_id": message_id,
|
||||
"normalized_subject": normalize_subject(subject),
|
||||
},
|
||||
"$inc": {"message_count": 1},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def ensure_thread_for_message(
|
||||
database,
|
||||
*,
|
||||
account_id: str,
|
||||
message: dict,
|
||||
resolution: ThreadResolution,
|
||||
) -> ObjectId:
|
||||
message_id = message["_id"]
|
||||
event_at = message_event_at(message)
|
||||
subject = message.get("subject", "")
|
||||
|
||||
if resolution.thread_id is not None:
|
||||
await attach_message_to_thread(
|
||||
database,
|
||||
account_id=account_id,
|
||||
thread_id=resolution.thread_id,
|
||||
message_id=message_id,
|
||||
subject=subject,
|
||||
event_at=event_at,
|
||||
)
|
||||
return resolution.thread_id
|
||||
|
||||
return await create_thread(
|
||||
database,
|
||||
account_id=account_id,
|
||||
subject=subject,
|
||||
message_id=message_id,
|
||||
event_at=event_at,
|
||||
)
|
||||
|
||||
|
||||
def threading_fields_from_parent(parent: dict) -> dict:
|
||||
parent_internet_id = normalize_message_id(parent.get("message_id"))
|
||||
parent_references = parent.get("references") or []
|
||||
if isinstance(parent_references, str):
|
||||
parent_references = [parent_references]
|
||||
references = []
|
||||
if parent_references:
|
||||
references = list(parent_references)
|
||||
if parent_internet_id:
|
||||
references = build_references_header(references, parent_internet_id).split()
|
||||
return {
|
||||
"in_reply_to": parent_internet_id,
|
||||
"references": references,
|
||||
"parent_message_id": parent["_id"],
|
||||
}
|
||||
33
backend/tests/test_handoff_evaluator.py
Normal file
33
backend/tests/test_handoff_evaluator.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from app.services.handoff_evaluator import evaluate_handoff
|
||||
|
||||
|
||||
def test_handoff_requires_all_selected_filters():
|
||||
email = {
|
||||
"subject": "Payment approval required",
|
||||
"snippet": "Please approve this invoice payment today.",
|
||||
"body_text": "We need your approval for payment of $4,500.",
|
||||
}
|
||||
result = evaluate_handoff(
|
||||
email=email,
|
||||
labels=["Important", "Finance"],
|
||||
email_types=["transactional"],
|
||||
instructions="Forward when approval is required.",
|
||||
)
|
||||
assert result["would_handoff"] is True
|
||||
assert result["label_matched"] is True
|
||||
assert result["type_matched"] is True
|
||||
|
||||
|
||||
def test_handoff_does_not_match_unrelated_instruction():
|
||||
email = {
|
||||
"subject": "Team lunch",
|
||||
"snippet": "Where should we eat tomorrow?",
|
||||
"body_text": "Let me know your preference.",
|
||||
}
|
||||
result = evaluate_handoff(
|
||||
email=email,
|
||||
labels=[],
|
||||
email_types=[],
|
||||
instructions="Forward when a refund requires approval.",
|
||||
)
|
||||
assert result["would_handoff"] is False
|
||||
124
backend/tests/test_human_handoff_service.py
Normal file
124
backend/tests/test_human_handoff_service.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import pytest
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.human_handoff_service import (
|
||||
MESSAGE_SCOPE_EXTERNAL,
|
||||
MESSAGE_SCOPE_HANDOFF,
|
||||
resolve_handoff_from_inbound,
|
||||
)
|
||||
|
||||
|
||||
class FakeCollection:
|
||||
def __init__(self, documents=None):
|
||||
self.documents = list(documents or [])
|
||||
|
||||
async def find_one(self, query):
|
||||
for document in self.documents:
|
||||
if all(document.get(key) == value for key, value in query.items()):
|
||||
return document
|
||||
if "$ne" in query.get("status", {}):
|
||||
if document.get("status") == query["status"]["$ne"]:
|
||||
continue
|
||||
for document in self.documents:
|
||||
matches = True
|
||||
for key, value in query.items():
|
||||
if key == "status" and isinstance(value, dict) and "$ne" in value:
|
||||
if document.get("status") == value["$ne"]:
|
||||
matches = False
|
||||
break
|
||||
elif document.get(key) != value:
|
||||
matches = False
|
||||
break
|
||||
if matches:
|
||||
return document
|
||||
return None
|
||||
|
||||
def find(self, query):
|
||||
class Cursor:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
|
||||
async def to_list(self, length):
|
||||
return self.items[:length]
|
||||
|
||||
filtered = [
|
||||
document
|
||||
for document in self.documents
|
||||
if all(document.get(key) == value for key, value in query.items())
|
||||
]
|
||||
return Cursor(filtered)
|
||||
|
||||
|
||||
class FakeDatabase:
|
||||
def __init__(self):
|
||||
self.human_handoff_rules = FakeCollection()
|
||||
self.messages = FakeCollection()
|
||||
self.mailbox_entries = FakeCollection()
|
||||
self.human_handoffs = FakeCollection()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_handoff_requires_owner_and_thread_context():
|
||||
database = FakeDatabase()
|
||||
handoff_thread_id = ObjectId()
|
||||
message_id = ObjectId()
|
||||
database.human_handoff_rules.documents = [
|
||||
{
|
||||
"account_id": "acct-1",
|
||||
"owner_email": "owner@gmail.com",
|
||||
"active": True,
|
||||
}
|
||||
]
|
||||
database.messages.documents = [
|
||||
{
|
||||
"_id": message_id,
|
||||
"message_id": "<handoff-1@gentic.email>",
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
]
|
||||
database.mailbox_entries.documents = [
|
||||
{
|
||||
"account_id": "acct-1",
|
||||
"message_id": message_id,
|
||||
"thread_id": handoff_thread_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
}
|
||||
]
|
||||
database.human_handoffs.documents = [
|
||||
{
|
||||
"_id": ObjectId(),
|
||||
"account_id": "acct-1",
|
||||
"handoff_thread_id": handoff_thread_id,
|
||||
"human_email": "owner@gmail.com",
|
||||
"status": "WAITING_FOR_HUMAN",
|
||||
}
|
||||
]
|
||||
|
||||
handoff = await resolve_handoff_from_inbound(
|
||||
database,
|
||||
account_id="acct-1",
|
||||
agent_email="support@gentic.email",
|
||||
sender_address="owner@gmail.com",
|
||||
in_reply_to="<handoff-1@gentic.email>",
|
||||
references=[],
|
||||
)
|
||||
assert handoff is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_handoff_rejects_unknown_sender():
|
||||
database = FakeDatabase()
|
||||
handoff = await resolve_handoff_from_inbound(
|
||||
database,
|
||||
account_id="acct-1",
|
||||
agent_email="support@gentic.email",
|
||||
sender_address="stranger@example.com",
|
||||
in_reply_to="<handoff-1@gentic.email>",
|
||||
references=[],
|
||||
)
|
||||
assert handoff is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_scope_constant_isolated_from_handoff():
|
||||
assert MESSAGE_SCOPE_EXTERNAL != MESSAGE_SCOPE_HANDOFF
|
||||
30
backend/tests/test_reply_recipients.py
Normal file
30
backend/tests/test_reply_recipients.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from app.services.reply_recipients import reply_recipients
|
||||
|
||||
|
||||
def test_reply_prefers_reply_to_header():
|
||||
message = {
|
||||
"sender": {"name": "Sender", "address": "sender@example.com"},
|
||||
"reply_to": [{"name": "Support", "address": "support@example.com"}],
|
||||
"to": [{"name": "Me", "address": "me@gentic.email"}],
|
||||
"cc": [],
|
||||
}
|
||||
to, cc = reply_recipients(message=message, viewer_email="me@gentic.email", reply_all=False)
|
||||
assert to == ["support@example.com"]
|
||||
assert cc == []
|
||||
|
||||
|
||||
def test_reply_all_excludes_viewer_and_deduplicates():
|
||||
message = {
|
||||
"sender": {"name": "Sender", "address": "sender@example.com"},
|
||||
"to": [
|
||||
{"name": "Me", "address": "me@gentic.email"},
|
||||
{"name": "Other", "address": "other@example.com"},
|
||||
],
|
||||
"cc": [
|
||||
{"name": "Sender", "address": "sender@example.com"},
|
||||
{"name": "Third", "address": "third@example.com"},
|
||||
],
|
||||
}
|
||||
to, cc = reply_recipients(message=message, viewer_email="me@gentic.email", reply_all=True)
|
||||
assert to == ["sender@example.com"]
|
||||
assert set(cc) == {"other@example.com", "third@example.com"}
|
||||
129
backend/tests/test_thread_resolver.py
Normal file
129
backend/tests/test_thread_resolver.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from datetime import datetime, timezone
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.message_ids import build_references_header, normalize_message_id, parse_message_id_list
|
||||
from app.services.subject_normalize import normalize_subject
|
||||
from app.services.thread_resolver import KnownMessage, resolve_thread_from_known
|
||||
|
||||
|
||||
def _known(
|
||||
internet_id: str,
|
||||
thread_id: ObjectId,
|
||||
account_id: str = "acct-1",
|
||||
participants: frozenset[str] | None = None,
|
||||
) -> KnownMessage:
|
||||
now = datetime.now(timezone.utc)
|
||||
return KnownMessage(
|
||||
id=ObjectId(),
|
||||
thread_id=thread_id,
|
||||
internet_message_id=normalize_message_id(internet_id) or "",
|
||||
account_id=account_id,
|
||||
sent_at=now,
|
||||
received_at=now,
|
||||
created_at=now,
|
||||
participants=participants or frozenset({"sender@example.com"}),
|
||||
normalized_subject=normalize_subject("Project update"),
|
||||
)
|
||||
|
||||
|
||||
def test_new_message_creates_new_thread():
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to=None,
|
||||
references=[],
|
||||
subject="Project update",
|
||||
participants={"sender@example.com"},
|
||||
known_by_internet_id={},
|
||||
)
|
||||
assert resolution.thread_id is None
|
||||
assert resolution.thread_resolution_method == "new_thread"
|
||||
assert resolution.thread_relation == "new_thread"
|
||||
|
||||
|
||||
def test_direct_reply_matches_in_reply_to():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<a@gentic.email>",
|
||||
references=["<a@gentic.email>"],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id == thread_id
|
||||
assert resolution.thread_resolution_method == "in_reply_to"
|
||||
assert resolution.parent_message_id == parent.id
|
||||
|
||||
|
||||
def test_references_fallback_when_parent_missing():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<b@example.com>",
|
||||
references=["<a@gentic.email>", "<b@example.com>"],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id == thread_id
|
||||
assert resolution.thread_resolution_method == "references"
|
||||
|
||||
|
||||
def test_fake_re_subject_does_not_thread():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to=None,
|
||||
references=[],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id is None
|
||||
assert resolution.thread_resolution_method == "new_thread"
|
||||
|
||||
|
||||
def test_changed_subject_with_valid_reply_headers():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id)
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<a@gentic.email>",
|
||||
references=[],
|
||||
subject="Completely changed subject",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id == thread_id
|
||||
|
||||
|
||||
def test_cross_tenant_message_is_ignored():
|
||||
thread_id = ObjectId()
|
||||
parent = _known("<a@gentic.email>", thread_id, account_id="other-acct")
|
||||
known = {parent.internet_message_id: parent}
|
||||
resolution = resolve_thread_from_known(
|
||||
account_id="acct-1",
|
||||
in_reply_to="<a@gentic.email>",
|
||||
references=[],
|
||||
subject="Re: Project update",
|
||||
participants={"rahul@example.com"},
|
||||
known_by_internet_id=known,
|
||||
)
|
||||
assert resolution.thread_id is None
|
||||
|
||||
|
||||
def test_build_references_header_appends_parent():
|
||||
header = build_references_header(["<a@gentic.email>"], "<b@example.com>")
|
||||
assert header == "<a@gentic.email> <b@example.com>"
|
||||
|
||||
|
||||
def test_parse_message_id_list_handles_folded_references():
|
||||
values = parse_message_id_list("<a@gentic.email>\r\n <b@example.com>")
|
||||
assert values == ["<a@gentic.email>", "<b@example.com>"]
|
||||
|
|
@ -101,6 +101,10 @@ function emailQuery(parameters = {}) {
|
|||
|
||||
export const listEmails = (parameters, token) =>
|
||||
request(`/emails${emailQuery(parameters)}`, { token });
|
||||
export const listThreads = (parameters, token) =>
|
||||
request(`/threads${emailQuery(parameters)}`, { token });
|
||||
export const getThread = (threadId, token) =>
|
||||
request(`/threads/${encodeURIComponent(threadId)}`, { token });
|
||||
export const getEmail = (emailId, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}`, { token });
|
||||
export const setEmailRead = (emailId, isRead, token) =>
|
||||
|
|
@ -131,6 +135,21 @@ export const permanentlyDeleteEmail = (emailId, token) =>
|
|||
token,
|
||||
});
|
||||
|
||||
export const listHandoffRules = (token) =>
|
||||
request("/agent/handoff", { token });
|
||||
export const createHandoffRule = (payload, token) =>
|
||||
request("/agent/handoff", { method: "POST", payload, token });
|
||||
export const updateHandoffRule = (ruleId, payload, token) =>
|
||||
request(`/agent/handoff/${encodeURIComponent(ruleId)}`, { method: "PATCH", payload, token });
|
||||
export const deleteHandoffRule = (ruleId, token) =>
|
||||
request(`/agent/handoff/${encodeURIComponent(ruleId)}`, { method: "DELETE", token });
|
||||
export const testHandoffRule = (payload, token) =>
|
||||
request("/agent/handoff/test", { method: "POST", payload, token });
|
||||
export const listHandoffConversations = (token) =>
|
||||
request("/agent/handoff/conversations", { token });
|
||||
export const getHandoffConversation = (handoffId, token) =>
|
||||
request(`/agent/handoff/conversations/${encodeURIComponent(handoffId)}`, { token });
|
||||
|
||||
export const listStickyNotes = (token) =>
|
||||
request("/sticky-notes", { token });
|
||||
export const createStickyNote = (payload, token) =>
|
||||
|
|
|
|||
|
|
@ -111,10 +111,24 @@ function emailContextActions(email) {
|
|||
return actions.slice(0, 3);
|
||||
}
|
||||
|
||||
function threadContextSummary(thread) {
|
||||
const messages = thread?.messages || [];
|
||||
if (messages.length <= 1) return null;
|
||||
const senders = [...new Set(messages.map((message) => message.sender?.name || message.sender?.address).filter(Boolean))];
|
||||
const latest = messages[messages.length - 1];
|
||||
const snippet = (latest?.body_text || latest?.snippet || "").trim();
|
||||
const preview = snippet.split(/[.!?\n]/).find((part) => part.trim().length > 8)?.trim();
|
||||
if (preview) {
|
||||
return `${senders.join(" and ")} exchanged ${messages.length} messages. Latest: “${preview.length > 100 ? `${preview.slice(0, 97)}…` : preview}.”`;
|
||||
}
|
||||
return `${senders.join(" and ")} exchanged ${messages.length} messages in this conversation.`;
|
||||
}
|
||||
|
||||
export default function AgAgentPanel({
|
||||
account,
|
||||
emails,
|
||||
contextEmail,
|
||||
contextThread,
|
||||
focus,
|
||||
insight,
|
||||
loading = false,
|
||||
|
|
@ -132,6 +146,7 @@ export default function AgAgentPanel({
|
|||
const allMetricsZero = metricTotal(stats) === 0;
|
||||
const hasQuery = agentQuery.trim().length > 0;
|
||||
const emailContextMode = Boolean(contextEmail) && !insight;
|
||||
const threadContextMode = Boolean(contextThread?.messages?.length > 1) && !insight;
|
||||
const showOnboarding = !insight && !loading && mailboxEmpty && isBrandNewMailbox;
|
||||
const showQuietEmpty = !insight && !loading && mailboxEmpty && !isBrandNewMailbox;
|
||||
const showFullPanel = !insight && !loading && !mailboxEmpty && !emailContextMode;
|
||||
|
|
@ -187,7 +202,7 @@ export default function AgAgentPanel({
|
|||
{emailContextMode && (
|
||||
<section className="agent-email-context">
|
||||
<h3>Summary</h3>
|
||||
<p>{emailContextSummary(contextEmail)}</p>
|
||||
<p>{threadContextMode ? threadContextSummary(contextThread) : emailContextSummary(contextEmail)}</p>
|
||||
<h3>Action items</h3>
|
||||
<ul className="agent-email-action-list">
|
||||
{emailContextActions(contextEmail).map((item) => (
|
||||
|
|
|
|||
|
|
@ -51,7 +51,27 @@ export default function ComposeDock({ account, onSent, agentOpen = true }) {
|
|||
focusRef.current = windowId;
|
||||
}, []);
|
||||
|
||||
const openCompose = useCallback(() => {
|
||||
const openCompose = useCallback((context = null) => {
|
||||
if (context) {
|
||||
const id = `compose-${Date.now()}`;
|
||||
focusRef.current = id;
|
||||
const draft = {
|
||||
to: context.to || [],
|
||||
cc: context.cc || [],
|
||||
bcc: context.bcc || [],
|
||||
subject: context.subject || "",
|
||||
bodyHtml: context.bodyHtml || "",
|
||||
replyToMessageId: context.replyToMessageId || null,
|
||||
replyAll: Boolean(context.replyAll),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(`agmail_draft_${id}`, JSON.stringify(draft));
|
||||
setWindows((current) => [
|
||||
{ id, minimized: false, expanded: false, focused: true, lastActive: Date.now() },
|
||||
...current,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
setWindows((current) => {
|
||||
const blank = current.find((window) => !window.minimized && isDraftEmpty(window.id));
|
||||
if (blank) {
|
||||
|
|
@ -250,6 +270,6 @@ export default function ComposeDock({ account, onSent, agentOpen = true }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function triggerOpenCompose() {
|
||||
window.agmailOpenCompose?.();
|
||||
export function triggerOpenCompose(context = null) {
|
||||
window.agmailOpenCompose?.(context);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ export default function ComposeModal({
|
|||
const [warnings, setWarnings] = useState([]);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [status, setStatus] = useState({ sending: false, error: "" });
|
||||
const [replyToMessageId, setReplyToMessageId] = useState(null);
|
||||
const [replyAll, setReplyAll] = useState(false);
|
||||
|
||||
const orgDomain = account.company_domain || account.email.split("@")[1];
|
||||
const draftKey = draftStorageKey(windowId || account.id);
|
||||
|
|
@ -216,7 +218,9 @@ export default function ComposeModal({
|
|||
], [account, fromAddress.email, orgDomain]);
|
||||
|
||||
const saveDraft = useCallback(() => {
|
||||
const payload = { to, cc, bcc, subject, bodyHtml, updatedAt: Date.now() };
|
||||
const payload = {
|
||||
to, cc, bcc, subject, bodyHtml, replyToMessageId, replyAll, updatedAt: Date.now(),
|
||||
};
|
||||
const empty = !subject && !bodyHtml && !to.length && !cc.length && !bcc.length;
|
||||
if (empty) {
|
||||
clearDraft(windowId || account.id);
|
||||
|
|
@ -243,6 +247,8 @@ export default function ComposeModal({
|
|||
setBcc(draft.bcc || []);
|
||||
setSubject(draft.subject || "");
|
||||
setBodyHtml(draft.bodyHtml || "");
|
||||
setReplyToMessageId(draft.replyToMessageId || null);
|
||||
setReplyAll(Boolean(draft.replyAll));
|
||||
setShowCc(Boolean(draft.cc?.length));
|
||||
setShowBcc(Boolean(draft.bcc?.length));
|
||||
if (editorRef.current && draft.bodyHtml) {
|
||||
|
|
@ -385,24 +391,26 @@ export default function ComposeModal({
|
|||
}
|
||||
setStatus({ sending: true, error: "" });
|
||||
try {
|
||||
await sendEmail(
|
||||
{
|
||||
recipients: recipientsToPayload(to),
|
||||
cc: recipientsToPayload(cc),
|
||||
bcc: recipientsToPayload(bcc),
|
||||
subject,
|
||||
body_text: plainTextFromHtml(bodyHtml),
|
||||
body_html: bodyHtml || null,
|
||||
},
|
||||
sessionStorage.getItem("agmail_access_token"),
|
||||
);
|
||||
const payload = {
|
||||
recipients: recipientsToPayload(to),
|
||||
cc: recipientsToPayload(cc),
|
||||
bcc: recipientsToPayload(bcc),
|
||||
subject,
|
||||
body_text: plainTextFromHtml(bodyHtml),
|
||||
body_html: bodyHtml || null,
|
||||
};
|
||||
if (replyToMessageId) {
|
||||
payload.reply_to_message_id = replyToMessageId;
|
||||
payload.reply_all = replyAll;
|
||||
}
|
||||
await sendEmail(payload, sessionStorage.getItem("agmail_access_token"));
|
||||
clearDraft(windowId || account.id);
|
||||
flashNotice("Message sent. Undo available for 5 seconds.");
|
||||
onSent?.();
|
||||
} catch (error) {
|
||||
setStatus({ sending: false, error: error.message });
|
||||
}
|
||||
}, [to, cc, bcc, subject, bodyHtml, windowId, account.id, onSent]);
|
||||
}, [to, cc, bcc, subject, bodyHtml, replyToMessageId, replyAll, windowId, account.id, onSent]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
HandHelping,
|
||||
Plug,
|
||||
Send,
|
||||
Settings,
|
||||
|
|
@ -38,6 +39,7 @@ import CompanyOverview from "./CompanyOverview";
|
|||
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||
import ChangePasswordModal from "./ChangePasswordModal";
|
||||
import ComposeDock, { triggerOpenCompose } from "./ComposeDock";
|
||||
import HumanHandoff from "./HumanHandoff";
|
||||
import MailboxView from "./MailboxView";
|
||||
|
||||
const mailItems = [
|
||||
|
|
@ -63,6 +65,7 @@ const agentItems = [
|
|||
[Sparkles, "Ask Agent", "ask"],
|
||||
[ListChecks, "Action Items", "actions"],
|
||||
[Sparkles, "Summaries", "summaries"],
|
||||
[HandHelping, "Human Handoff", "handoff"],
|
||||
[Plug, "Integrations", "integrations"],
|
||||
];
|
||||
|
||||
|
|
@ -204,8 +207,8 @@ export default function DashboardPage() {
|
|||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
function openCompose() {
|
||||
triggerOpenCompose();
|
||||
function openCompose(context = null) {
|
||||
triggerOpenCompose(context);
|
||||
}
|
||||
|
||||
function emailSent() {
|
||||
|
|
@ -409,7 +412,10 @@ export default function DashboardPage() {
|
|||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{!companyActive && active === "Inbox" && (
|
||||
{!companyActive && active === "Inbox" && agentNav === "handoff" && (
|
||||
<HumanHandoff account={account} />
|
||||
)}
|
||||
{!companyActive && active === "Inbox" && agentNav !== "handoff" && (
|
||||
<MailboxView
|
||||
mailbox="inbox"
|
||||
title="Inbox"
|
||||
|
|
|
|||
74
frontend/src/components/HandoffConversation.jsx
Normal file
74
frontend/src/components/HandoffConversation.jsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { ArrowLeft } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getHandoffConversation } from "../api";
|
||||
|
||||
function formatTime(dateValue) {
|
||||
if (!dateValue) return "";
|
||||
return new Date(dateValue).toLocaleString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function HandoffConversation({ handoffId, onBack }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [conversation, setConversation] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await getHandoffConversation(handoffId, token);
|
||||
setConversation(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handoffId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="people-table-empty">Loading conversation…</p>;
|
||||
}
|
||||
if (error) {
|
||||
return <p className="form-error" role="alert">{error}</p>;
|
||||
}
|
||||
if (!conversation) return null;
|
||||
|
||||
return (
|
||||
<div className="handoff-conversation">
|
||||
<button type="button" className="handoff-back-btn" onClick={onBack}>
|
||||
<ArrowLeft size={16} strokeWidth={1.75} /> Back to Handoffs
|
||||
</button>
|
||||
|
||||
<header className="handoff-conversation-head">
|
||||
<h2>{conversation.external_subject || "(No subject)"}</h2>
|
||||
<div className="handoff-linked-external">
|
||||
<span>Linked conversation:</span>
|
||||
<strong>{conversation.linked_external.sender}</strong>
|
||||
<em>“{conversation.linked_external.subject}”</em>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="handoff-message-list">
|
||||
{conversation.messages.map((message) => (
|
||||
<article key={message.id} className={`handoff-message handoff-message-${message.role}`}>
|
||||
<header>
|
||||
<strong>{message.role === "human" ? "HUMAN OWNER" : "AGENT"}</strong>
|
||||
<time>{formatTime(message.event_at)}</time>
|
||||
</header>
|
||||
<div className="handoff-message-body">
|
||||
{message.body_text || message.snippet}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/HandoffInbox.jsx
Normal file
87
frontend/src/components/HandoffInbox.jsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { listHandoffConversations } from "../api";
|
||||
|
||||
function formatRelativeTime(dateValue) {
|
||||
if (!dateValue) return "";
|
||||
const date = new Date(dateValue);
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function HandoffInbox({ onSelect }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listHandoffConversations(token);
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="handoff-inbox">
|
||||
<p className="handoff-inbox-lead">Agent ↔ Human Owner conversations</p>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="people-table-empty">Loading handoffs…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="human-handoff-empty">
|
||||
<div className="human-handoff-empty-icon" aria-hidden="true">
|
||||
<ArrowLeftRight size={24} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h2>No handoff conversations yet</h2>
|
||||
<p>
|
||||
When the Agent hands off a customer conversation, it appears here as a private
|
||||
Agent ↔ Human thread — separate from your normal Inbox.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="handoff-inbox-list">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="handoff-inbox-row"
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<div className="handoff-inbox-row-main">
|
||||
<strong>{item.external_subject || "(No subject)"}</strong>
|
||||
<span className="handoff-inbox-row-party">Human Owner</span>
|
||||
{item.snippet && <p className="handoff-inbox-row-snippet">“{item.snippet}”</p>}
|
||||
</div>
|
||||
<div className="handoff-inbox-row-meta">
|
||||
<time>{formatRelativeTime(item.updated_at)}</time>
|
||||
<span>{item.message_count} message{item.message_count === 1 ? "" : "s"}</span>
|
||||
<span className={`handoff-inbox-status status-${item.status.toLowerCase()}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
frontend/src/components/HandoffSettings.jsx
Normal file
294
frontend/src/components/HandoffSettings.jsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { MoreHorizontal, Plus, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createHandoffRule,
|
||||
deleteHandoffRule,
|
||||
listHandoffRules,
|
||||
testHandoffRule,
|
||||
updateHandoffRule,
|
||||
} from "../api";
|
||||
import AgentSpark from "./AgentSpark";
|
||||
import HumanHandoffDrawer from "./HumanHandoffDrawer";
|
||||
import { buildRuleCardSummary, EMAIL_TYPE_OPTIONS } from "../utils/handoffUtils";
|
||||
|
||||
function typeLabel(id) {
|
||||
return EMAIL_TYPE_OPTIONS.find((item) => item.id === id)?.title || id;
|
||||
}
|
||||
|
||||
export default function HandoffSettings() {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [rules, setRules] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState(null);
|
||||
const [testOpen, setTestOpen] = useState(false);
|
||||
const [testDraft, setTestDraft] = useState(null);
|
||||
const [testSubject, setTestSubject] = useState("");
|
||||
const [testBody, setTestBody] = useState("");
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listHandoffRules(token);
|
||||
setRules(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingRule(null);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(rule) {
|
||||
setEditingRule(rule);
|
||||
setDrawerOpen(true);
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
|
||||
async function handleSave(payload) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editingRule) {
|
||||
await updateHandoffRule(editingRule.id, payload, token);
|
||||
setNotice("Human Handoff updated.");
|
||||
} else {
|
||||
await createHandoffRule(payload, token);
|
||||
setNotice("Human Handoff created.");
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
setEditingRule(null);
|
||||
await loadRules();
|
||||
window.setTimeout(() => setNotice(""), 2500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(rule) {
|
||||
if (!window.confirm("Delete this handoff rule?")) return;
|
||||
try {
|
||||
await deleteHandoffRule(rule.id, token);
|
||||
setMenuOpenId(null);
|
||||
await loadRules();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openTest(draft) {
|
||||
setTestDraft(draft);
|
||||
setTestSubject("");
|
||||
setTestBody("");
|
||||
setTestResult(null);
|
||||
setTestOpen(true);
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (!testDraft) return;
|
||||
setTesting(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await testHandoffRule({
|
||||
rule_id: editingRule?.id,
|
||||
owner_email: testDraft.owner_email,
|
||||
labels: testDraft.labels,
|
||||
email_types: testDraft.email_types,
|
||||
instructions: testDraft.instructions,
|
||||
email: {
|
||||
subject: testSubject,
|
||||
snippet: testBody,
|
||||
body_text: testBody,
|
||||
},
|
||||
}, token);
|
||||
setTestResult(result);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="handoff-settings">
|
||||
<div className="handoff-settings-intro">
|
||||
<p>
|
||||
<strong>Your Agent handles email. You step in when needed.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Human Handoff sends selected conversations to the Agent's Human Owner. The owner can
|
||||
reply from their normal inbox, and Gentic carries that response back to the original sender
|
||||
without exposing or mixing the private Agent ↔ Human conversation.
|
||||
</p>
|
||||
<div className="handoff-flow-diagram" aria-hidden="true">
|
||||
<span>External Sender</span>
|
||||
<span className="handoff-flow-arrow">↕</span>
|
||||
<span>Gentic Agent</span>
|
||||
<span className="handoff-flow-arrow">↕</span>
|
||||
<span>Human Owner</span>
|
||||
</div>
|
||||
<small>
|
||||
The external conversation and Human Handoff remain separate and independently threaded.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="handoff-settings-toolbar">
|
||||
<button type="button" className="handoff-btn primary" onClick={openCreate}>
|
||||
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
{notice && <p className="security-notice" role="status">{notice}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="people-table-empty">Loading settings…</p>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="human-handoff-empty">
|
||||
<div className="human-handoff-empty-icon" aria-hidden="true">
|
||||
<UserRound size={24} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h2>No handoff rules yet</h2>
|
||||
<p>
|
||||
Configure when your Agent should hand conversations to the Human Owner.
|
||||
Handoff transport messages stay out of your normal Inbox and Sent folders.
|
||||
</p>
|
||||
<button type="button" className="handoff-btn primary" onClick={openCreate}>
|
||||
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="human-handoff-list">
|
||||
{rules.map((rule) => (
|
||||
<article key={rule.id} className="human-handoff-card">
|
||||
<div className="human-handoff-card-top">
|
||||
<span className="human-handoff-card-avatar" aria-hidden="true">
|
||||
<UserRound size={18} strokeWidth={1.75} />
|
||||
</span>
|
||||
<div className="human-handoff-card-meta">
|
||||
<strong>{rule.owner_name}</strong>
|
||||
<small>{rule.owner_email}</small>
|
||||
</div>
|
||||
<span className={`human-handoff-status${rule.active ? " active" : ""}`}>
|
||||
{rule.active ? "Active" : "Paused"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="human-handoff-card-summary">{buildRuleCardSummary(rule)}</p>
|
||||
{rule.instructions && (
|
||||
<blockquote className="human-handoff-card-quote">“{rule.instructions}”</blockquote>
|
||||
)}
|
||||
<div className="human-handoff-card-actions">
|
||||
<button type="button" className="handoff-btn subtle" onClick={() => openEdit(rule)}>Edit</button>
|
||||
<div className="handoff-card-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="handoff-btn icon"
|
||||
aria-label="More actions"
|
||||
onClick={() => setMenuOpenId((current) => (current === rule.id ? null : rule.id))}
|
||||
>
|
||||
<MoreHorizontal size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
{menuOpenId === rule.id && (
|
||||
<div className="handoff-card-menu">
|
||||
<button type="button" onClick={() => openTest({
|
||||
owner_email: rule.owner_email,
|
||||
labels: rule.labels,
|
||||
email_types: rule.email_types,
|
||||
instructions: rule.instructions,
|
||||
})}>Test with an email</button>
|
||||
<button type="button" onClick={() => handleDelete(rule)}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HumanHandoffDrawer
|
||||
open={drawerOpen}
|
||||
rule={editingRule}
|
||||
saving={saving}
|
||||
onClose={() => { setDrawerOpen(false); setEditingRule(null); }}
|
||||
onSave={handleSave}
|
||||
onTest={(draft) => openTest(draft)}
|
||||
/>
|
||||
|
||||
{testOpen && (
|
||||
<div className="handoff-drawer-backdrop" role="presentation" onClick={() => setTestOpen(false)}>
|
||||
<aside className="handoff-test-modal" role="dialog" aria-labelledby="handoff-test-title" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="handoff-drawer-head">
|
||||
<div>
|
||||
<h2 id="handoff-test-title">Test Human Handoff</h2>
|
||||
<p>Check whether your current rules would cause a particular email to be handed to you.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="handoff-drawer-body">
|
||||
<label className="handoff-field-label" htmlFor="handoff-test-subject">Subject</label>
|
||||
<input
|
||||
id="handoff-test-subject"
|
||||
className="handoff-text-input"
|
||||
value={testSubject}
|
||||
onChange={(event) => setTestSubject(event.target.value)}
|
||||
placeholder="Payment approval required"
|
||||
/>
|
||||
<label className="handoff-field-label" htmlFor="handoff-test-body">Email content</label>
|
||||
<textarea
|
||||
id="handoff-test-body"
|
||||
className="handoff-instructions"
|
||||
rows={6}
|
||||
value={testBody}
|
||||
onChange={(event) => setTestBody(event.target.value)}
|
||||
placeholder="Paste or describe the email you want to test…"
|
||||
/>
|
||||
{testResult && (
|
||||
<div className="handoff-test-result">
|
||||
<h3>Handoff test</h3>
|
||||
<ul>
|
||||
<li className={testResult.label_matched ? "pass" : "fail"}>
|
||||
{testResult.label_matched ? "✓" : "✕"} Label matched
|
||||
{testResult.matched_labels?.length ? ` — ${testResult.matched_labels.join(", ")}` : ""}
|
||||
</li>
|
||||
<li className={testResult.type_matched ? "pass" : "fail"}>
|
||||
{testResult.type_matched ? "✓" : "✕"} Type matched
|
||||
{testResult.matched_types?.length ? ` — ${testResult.matched_types.map(typeLabel).join(", ")}` : ""}
|
||||
</li>
|
||||
<li className={testResult.instruction_matched ? "pass" : "fail"}>
|
||||
{testResult.instruction_matched ? "✓" : "✕"} Handoff instruction matched
|
||||
</li>
|
||||
</ul>
|
||||
<p><strong>Result:</strong> {testResult.result_path}</p>
|
||||
<p><strong>Reason:</strong> {testResult.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="handoff-drawer-foot">
|
||||
<button type="button" className="handoff-btn subtle" onClick={() => setTestOpen(false)}>Close</button>
|
||||
<button type="button" className="handoff-btn primary" disabled={testing} onClick={runTest}>
|
||||
<AgentSpark /> {testing ? "Testing…" : "Run test"}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/HumanHandoff.jsx
Normal file
58
frontend/src/components/HumanHandoff.jsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState } from "react";
|
||||
import HandoffConversation from "./HandoffConversation";
|
||||
import HandoffInbox from "./HandoffInbox";
|
||||
import HandoffSettings from "./HandoffSettings";
|
||||
|
||||
export default function HumanHandoff() {
|
||||
const [tab, setTab] = useState("handoffs");
|
||||
const [selectedHandoffId, setSelectedHandoffId] = useState(null);
|
||||
|
||||
function switchTab(nextTab) {
|
||||
setTab(nextTab);
|
||||
if (nextTab !== "handoffs") {
|
||||
setSelectedHandoffId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="human-handoff-page">
|
||||
<header className="human-handoff-head">
|
||||
<div>
|
||||
<h1>Human Handoff</h1>
|
||||
<p>
|
||||
Private Agent ↔ Human Owner conversations, linked to external customer threads
|
||||
but kept on separate RFC threading planes.
|
||||
</p>
|
||||
<nav className="human-handoff-tabs" aria-label="Human Handoff sections">
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "handoffs" ? "active" : ""}
|
||||
onClick={() => switchTab("handoffs")}
|
||||
>
|
||||
Handoffs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "settings" ? "active" : ""}
|
||||
onClick={() => switchTab("settings")}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tab === "handoffs" && (
|
||||
selectedHandoffId ? (
|
||||
<HandoffConversation
|
||||
handoffId={selectedHandoffId}
|
||||
onBack={() => setSelectedHandoffId(null)}
|
||||
/>
|
||||
) : (
|
||||
<HandoffInbox onSelect={setSelectedHandoffId} />
|
||||
)
|
||||
)}
|
||||
{tab === "settings" && <HandoffSettings />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
292
frontend/src/components/HumanHandoffDrawer.jsx
Normal file
292
frontend/src/components/HumanHandoffDrawer.jsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import { Mail, UserRound, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AgentSpark from "./AgentSpark";
|
||||
import {
|
||||
buildHandoffSummary,
|
||||
EMAIL_TYPE_OPTIONS,
|
||||
HANDOFF_LABELS,
|
||||
INSTRUCTION_EXAMPLES,
|
||||
} from "../utils/handoffUtils";
|
||||
|
||||
function ToggleSwitch({ checked, onChange, label }) {
|
||||
return (
|
||||
<label className="handoff-switch">
|
||||
<span>{label}</span>
|
||||
<input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
||||
<span className="handoff-switch-track" aria-hidden="true" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HumanHandoffDrawer({
|
||||
open,
|
||||
rule,
|
||||
saving,
|
||||
onClose,
|
||||
onSave,
|
||||
onTest,
|
||||
}) {
|
||||
const [ownerEmail, setOwnerEmail] = useState("");
|
||||
const [labels, setLabels] = useState([]);
|
||||
const [emailTypes, setEmailTypes] = useState([]);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [allowReplies, setAllowReplies] = useState(true);
|
||||
const [replyAs, setReplyAs] = useState("agent");
|
||||
const [active, setActive] = useState(true);
|
||||
const [labelQuery, setLabelQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setOwnerEmail(rule?.owner_email || "");
|
||||
setLabels(rule?.labels || []);
|
||||
setEmailTypes(rule?.email_types || []);
|
||||
setInstructions(rule?.instructions || "");
|
||||
setAllowReplies(rule?.allow_replies ?? true);
|
||||
setReplyAs(rule?.reply_as || "agent");
|
||||
setActive(rule?.active ?? true);
|
||||
setLabelQuery("");
|
||||
}, [open, rule]);
|
||||
|
||||
const summary = useMemo(
|
||||
() => buildHandoffSummary({
|
||||
ownerEmail,
|
||||
labels,
|
||||
emailTypes,
|
||||
instructions,
|
||||
}),
|
||||
[ownerEmail, labels, emailTypes, instructions],
|
||||
);
|
||||
|
||||
const filteredLabels = HANDOFF_LABELS.filter((label) =>
|
||||
label.toLowerCase().includes(labelQuery.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
function toggleLabel(label) {
|
||||
setLabels((current) => (
|
||||
current.includes(label)
|
||||
? current.filter((item) => item !== label)
|
||||
: [...current, label]
|
||||
));
|
||||
}
|
||||
|
||||
function toggleEmailType(typeId) {
|
||||
setEmailTypes((current) => (
|
||||
current.includes(typeId)
|
||||
? current.filter((item) => item !== typeId)
|
||||
: [...current, typeId]
|
||||
));
|
||||
}
|
||||
|
||||
function insertExample(example) {
|
||||
setInstructions((current) => {
|
||||
const trimmed = current.trim();
|
||||
const addition = example.toLowerCase();
|
||||
if (!trimmed) return `Hand off when there is a ${addition}.`;
|
||||
if (trimmed.toLowerCase().includes(addition)) return current;
|
||||
return `${trimmed.replace(/\.$/, "")}, or when there is a ${addition}.`;
|
||||
});
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="handoff-drawer-backdrop" role="presentation" onClick={onClose}>
|
||||
<aside
|
||||
className="handoff-drawer"
|
||||
role="dialog"
|
||||
aria-labelledby="handoff-drawer-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="handoff-drawer-head">
|
||||
<div>
|
||||
<h2 id="handoff-drawer-title">{rule ? "Edit Human Handoff" : "Add Human Handoff"}</h2>
|
||||
<p>Choose who should receive emails that need human attention and define when AGMail should hand them off.</p>
|
||||
</div>
|
||||
<button type="button" className="handoff-drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="handoff-drawer-body">
|
||||
<section className="handoff-form-section">
|
||||
<h3>Human owner</h3>
|
||||
<label className="handoff-field-label" htmlFor="handoff-owner-email">Owner email address</label>
|
||||
<div className="handoff-email-input">
|
||||
<Mail size={16} strokeWidth={1.75} />
|
||||
<input
|
||||
id="handoff-owner-email"
|
||||
type="email"
|
||||
value={ownerEmail}
|
||||
placeholder="you@company.com"
|
||||
onChange={(event) => setOwnerEmail(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="handoff-helper">This is the human owner behind this agent. Emails matching your handoff rules will be sent here.</p>
|
||||
{ownerEmail.includes("@") && (
|
||||
<div className="handoff-owner-preview">
|
||||
<span className="handoff-owner-avatar" aria-hidden="true">
|
||||
<UserRound size={16} strokeWidth={1.75} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{ownerEmail.split("@")[0].replace(/[._-]/g, " ")}</strong>
|
||||
<small>{ownerEmail}</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="handoff-form-section">
|
||||
<h3>Hand off emails with these labels</h3>
|
||||
<p className="handoff-helper">An email matching any selected label will be eligible for handoff.</p>
|
||||
<div className="handoff-label-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search labels…"
|
||||
value={labelQuery}
|
||||
onChange={(event) => setLabelQuery(event.target.value)}
|
||||
/>
|
||||
<button type="button" onClick={() => setLabels([...HANDOFF_LABELS])}>Select all</button>
|
||||
</div>
|
||||
<div className="handoff-label-grid">
|
||||
{filteredLabels.map((label) => (
|
||||
<label key={label} className="handoff-check-pill">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={labels.includes(label)}
|
||||
onChange={() => toggleLabel(label)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="handoff-form-section">
|
||||
<h3>Hand off these types of email</h3>
|
||||
<p className="handoff-helper">Emails matching any selected type can be considered for handoff.</p>
|
||||
<div className="handoff-type-grid">
|
||||
{EMAIL_TYPE_OPTIONS.map((type) => (
|
||||
<label key={type.id} className={`handoff-type-card${emailTypes.includes(type.id) ? " selected" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailTypes.includes(type.id)}
|
||||
onChange={() => toggleEmailType(type.id)}
|
||||
/>
|
||||
<strong>{type.title}</strong>
|
||||
<span>{type.description}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="handoff-form-section">
|
||||
<h3>When should the agent hand off?</h3>
|
||||
<p className="handoff-helper">Write this naturally, just as you would instruct your agent.</p>
|
||||
<textarea
|
||||
className="handoff-instructions"
|
||||
rows={5}
|
||||
value={instructions}
|
||||
placeholder="e.g. Hand off when a sender asks to speak with me, when a payment or approval requires my decision, or whenever the agent is not confident it should respond on my behalf."
|
||||
onChange={(event) => setInstructions(event.target.value)}
|
||||
/>
|
||||
<p className="handoff-helper">Be specific about intent, urgency, topics, amounts, approvals or anything that should require your attention.</p>
|
||||
<div className="handoff-examples">
|
||||
<span>Examples:</span>
|
||||
{INSTRUCTION_EXAMPLES.map((example) => (
|
||||
<button key={example} type="button" onClick={() => insertExample(example)}>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="handoff-summary-card">
|
||||
<div className="handoff-summary-title">
|
||||
<AgentSpark />
|
||||
<span>Handoff behavior</span>
|
||||
</div>
|
||||
<p>{summary}</p>
|
||||
</section>
|
||||
|
||||
<section className="handoff-form-section">
|
||||
<h3>Continue the conversation from my inbox</h3>
|
||||
<ToggleSwitch
|
||||
label="Allow human replies"
|
||||
checked={allowReplies}
|
||||
onChange={setAllowReplies}
|
||||
/>
|
||||
<p className="handoff-helper">
|
||||
When you reply to a handed-off email from your regular inbox, AGMail will recognize your response and continue the original conversation through the agent's email address.
|
||||
</p>
|
||||
{allowReplies && (
|
||||
<div className="handoff-reply-info">
|
||||
<strong>How it works</strong>
|
||||
<p>Email arrives → Agent handles it. If human attention is required → Human Handoff → Your inbox. You reply normally → AGMail → Original conversation.</p>
|
||||
</div>
|
||||
)}
|
||||
{allowReplies && (
|
||||
<div className="handoff-reply-as">
|
||||
<strong>Replies will be sent as</strong>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="reply-as"
|
||||
checked={replyAs === "agent"}
|
||||
onChange={() => setReplyAs("agent")}
|
||||
/>
|
||||
<span>Original AGMail address</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="reply-as"
|
||||
checked={replyAs === "human"}
|
||||
onChange={() => setReplyAs("human")}
|
||||
/>
|
||||
<span>Human's email address</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="handoff-form-section">
|
||||
<ToggleSwitch label="Human Handoff active" checked={active} onChange={setActive} />
|
||||
<p className="handoff-helper">When disabled, your agent will continue receiving email but will no longer hand new conversations to the human owner.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="handoff-drawer-foot">
|
||||
<button type="button" className="handoff-btn subtle" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className="handoff-btn subtle"
|
||||
onClick={() => onTest?.({
|
||||
owner_email: ownerEmail,
|
||||
labels,
|
||||
email_types: emailTypes,
|
||||
instructions,
|
||||
})}
|
||||
>
|
||||
<AgentSpark /> Test Handoff
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="handoff-btn primary"
|
||||
disabled={saving || !ownerEmail.trim()}
|
||||
onClick={() => onSave({
|
||||
owner_email: ownerEmail.trim(),
|
||||
labels,
|
||||
email_types: emailTypes,
|
||||
instructions: instructions.trim(),
|
||||
allow_replies: allowReplies,
|
||||
reply_as: replyAs,
|
||||
active,
|
||||
})}
|
||||
>
|
||||
{rule ? "Save Human Handoff" : "Create handoff"}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -138,14 +138,123 @@ function MessageAction({ title, label, onClick, active, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
function MessageBlock({
|
||||
message,
|
||||
viewerEmail,
|
||||
expanded,
|
||||
onToggle,
|
||||
isLatest,
|
||||
onToggleStar,
|
||||
onReply,
|
||||
onMove,
|
||||
}) {
|
||||
const [headersOpen, setHeadersOpen] = useState(false);
|
||||
const body = messageBody(message);
|
||||
const time = detailTime(message);
|
||||
const sender = senderIdentity(message);
|
||||
const attachments = message?.attachments || [];
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button type="button" className="mail-thread-message-collapsed" onClick={onToggle}>
|
||||
<SenderAvatar email={message} />
|
||||
<span className="mail-thread-message-collapsed-main">
|
||||
<strong>{sender?.name}</strong>
|
||||
<span>{message.snippet || message.body_text || ""}</span>
|
||||
</span>
|
||||
<time>{time.absolute}</time>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mail-thread-message">
|
||||
<div className="mail-detail-message-header">
|
||||
<SenderAvatar email={message} />
|
||||
<div className="mail-detail-sender-meta">
|
||||
<div className="mail-detail-sender-line">
|
||||
<strong>{sender?.name}</strong>
|
||||
{sender?.address && (
|
||||
<span className="mail-detail-sender-email"><{sender.address}></span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`mail-detail-recipient-toggle${headersOpen ? " open" : ""}`}
|
||||
onClick={() => setHeadersOpen((open) => !open)}
|
||||
aria-expanded={headersOpen}
|
||||
>
|
||||
{recipientLabel(message, viewerEmail)}
|
||||
<ChevronDown size={12} strokeWidth={1.75} />
|
||||
</button>
|
||||
{headersOpen && (
|
||||
<div className="mail-detail-recipient-details">
|
||||
<small>From: {joinAddresses(message.sender) || sender?.display}</small>
|
||||
<small>To: {joinAddresses(message.recipients || message.to) || "Undisclosed"}</small>
|
||||
{joinAddresses(message.cc) ? <small>Cc: {joinAddresses(message.cc)}</small> : null}
|
||||
<small>Date: {time.absolute}{time.relative ? ` (${time.relative})` : ""}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mail-detail-message-header-actions">
|
||||
<time dateTime={message.created_at}>
|
||||
{time.absolute}
|
||||
{time.relative ? ` (${time.relative})` : ""}
|
||||
</time>
|
||||
<MessageAction
|
||||
title={message.is_starred ? "Unstar" : "Star"}
|
||||
active={message.is_starred}
|
||||
onClick={() => onToggleStar?.(!message.is_starred)}
|
||||
>
|
||||
<Star {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
{isLatest ? (
|
||||
<MessageAction title="Reply" onClick={() => onReply?.(false)}>
|
||||
<Reply {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
) : null}
|
||||
<MessageAction title="More actions" onClick={onMove}>
|
||||
<MoreHorizontal {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mail-detail-message-content${body?.mode === "html" ? " is-html" : ""}`}>
|
||||
<div className={`mail-detail-body${body?.mode === "html" ? " is-html" : " is-text"}`}>
|
||||
{body?.mode === "html" ? (
|
||||
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
|
||||
) : (
|
||||
<div className="mail-detail-body-text">{body?.value}</div>
|
||||
)}
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<div className="mail-detail-attachments" aria-label="Attachments">
|
||||
{attachments.map((file) => (
|
||||
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment">
|
||||
<span className="mail-detail-attachment-icon" aria-hidden="true">
|
||||
<FileText size={18} strokeWidth={1.7} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{file.filename}</strong>
|
||||
<small>{file.content_type || "File"} · {formatFileSize(file.size)}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MailDetail({
|
||||
email,
|
||||
thread,
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
mailbox,
|
||||
busy,
|
||||
viewerEmail,
|
||||
threadSize = 1,
|
||||
onClose,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
|
|
@ -162,14 +271,15 @@ export default function MailDetail({
|
|||
onSummarizeThread,
|
||||
onAnalyzeAttachment,
|
||||
}) {
|
||||
const [headersOpen, setHeadersOpen] = useState(false);
|
||||
const files = email ? attachmentCount(email) : 0;
|
||||
const body = email ? messageBody(email) : null;
|
||||
const time = email ? detailTime(email) : { absolute: "", relative: "" };
|
||||
const attachments = email?.attachments || [];
|
||||
const sender = email ? senderIdentity(email) : null;
|
||||
const summarizeLabel = threadSize > 1 ? "Summarize thread" : "Summarize";
|
||||
const showReplyAll = email ? hasMultipleRecipients(email) : false;
|
||||
const conversation = messages?.length ? messages : email ? [email] : [];
|
||||
const isThread = conversation.length > 1;
|
||||
const [expandedMessageId, setExpandedMessageId] = useState(null);
|
||||
const latestMessage = conversation[conversation.length - 1] || email;
|
||||
const files = latestMessage ? attachmentCount(latestMessage) : 0;
|
||||
const summarizeLabel = isThread ? "Summarize thread" : "Summarize";
|
||||
const showReplyAll = latestMessage ? hasMultipleRecipients(latestMessage) : false;
|
||||
const displaySubject = thread?.subject || email?.subject;
|
||||
const activeExpandedId = expandedMessageId || latestMessage?.id;
|
||||
|
||||
return (
|
||||
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
|
||||
|
|
@ -244,10 +354,13 @@ export default function MailDetail({
|
|||
<div className="mail-detail-sheet">
|
||||
<div className="mail-detail-subject-row">
|
||||
<div className="mail-detail-subject-main">
|
||||
<h1>{email.subject?.trim() || "(No subject)"}</h1>
|
||||
<h1>{displaySubject?.trim() || "(No subject)"}</h1>
|
||||
<div className="mail-detail-labels">
|
||||
<span className="mail-detail-label-pill">{mailboxLabel(mailbox)}</span>
|
||||
{email.is_starred && <span className="mail-detail-label-pill starred">Starred</span>}
|
||||
{isThread && (
|
||||
<span className="mail-detail-label-pill">{conversation.length} messages</span>
|
||||
)}
|
||||
{email?.is_starred && <span className="mail-detail-label-pill starred">Starred</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mail-detail-ai-chips">
|
||||
|
|
@ -265,95 +378,30 @@ export default function MailDetail({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mail-detail-message-header">
|
||||
<SenderAvatar email={email} />
|
||||
<div className="mail-detail-sender-meta">
|
||||
<div className="mail-detail-sender-line">
|
||||
<strong>{sender?.name}</strong>
|
||||
{sender?.address && (
|
||||
<span className="mail-detail-sender-email"><{sender.address}></span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`mail-detail-recipient-toggle${headersOpen ? " open" : ""}`}
|
||||
onClick={() => setHeadersOpen((open) => !open)}
|
||||
aria-expanded={headersOpen}
|
||||
>
|
||||
{recipientLabel(email, viewerEmail)}
|
||||
<ChevronDown size={12} strokeWidth={1.75} />
|
||||
</button>
|
||||
{headersOpen && (
|
||||
<div className="mail-detail-recipient-details">
|
||||
<small>From: {joinAddresses(email.sender) || sender?.display}</small>
|
||||
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
|
||||
{joinAddresses(email.cc) ? <small>Cc: {joinAddresses(email.cc)}</small> : null}
|
||||
{joinAddresses(email.bcc) ? <small>Bcc: {joinAddresses(email.bcc)}</small> : null}
|
||||
<small>Date: {time.absolute}{time.relative ? ` (${time.relative})` : ""}</small>
|
||||
<small>Subject: {email.subject?.trim() || "(No subject)"}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mail-detail-message-header-actions">
|
||||
<time dateTime={email.created_at}>
|
||||
{time.absolute}
|
||||
{time.relative ? ` (${time.relative})` : ""}
|
||||
</time>
|
||||
<MessageAction
|
||||
title={email.is_starred ? "Unstar" : "Star"}
|
||||
label={email.is_starred ? "Unstar" : "Star"}
|
||||
active={email.is_starred}
|
||||
onClick={() => onToggleStar?.(!email.is_starred)}
|
||||
>
|
||||
<Star {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
<MessageAction title="Reply" onClick={onReply}>
|
||||
<Reply {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
<MessageAction title="More actions" onClick={onMove}>
|
||||
<MoreHorizontal {...ACTION_ICON} />
|
||||
</MessageAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mail-detail-message-content${body?.mode === "html" ? " is-html" : ""}`}>
|
||||
<div className={`mail-detail-body${body?.mode === "html" ? " is-html" : " is-text"}`}>
|
||||
{body?.mode === "html" ? (
|
||||
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
|
||||
) : (
|
||||
<div className="mail-detail-body-text">{body?.value}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mail-detail-attachments" aria-label="Attachments">
|
||||
{attachments.map((file) => (
|
||||
<div key={`${file.filename}-${file.size}`} className="mail-detail-attachment">
|
||||
<span className="mail-detail-attachment-icon" aria-hidden="true">
|
||||
<FileText size={18} strokeWidth={1.7} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{file.filename}</strong>
|
||||
<small>{file.content_type || "File"} · {formatFileSize(file.size)}</small>
|
||||
</div>
|
||||
<div className="mail-detail-attachment-actions">
|
||||
<button type="button">Preview</button>
|
||||
<button type="button">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`mail-thread-messages${isThread ? " is-thread" : ""}`}>
|
||||
{conversation.map((message, index) => (
|
||||
<MessageBlock
|
||||
key={message.id}
|
||||
message={message}
|
||||
viewerEmail={viewerEmail}
|
||||
expanded={!isThread || message.id === activeExpandedId}
|
||||
onToggle={() => setExpandedMessageId(message.id)}
|
||||
isLatest={index === conversation.length - 1}
|
||||
onToggleStar={onToggleStar}
|
||||
onReply={onReply}
|
||||
onMove={onMove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mail-detail-message-footer">
|
||||
<div className="mail-detail-reply-bar">
|
||||
{showReplyAll ? (
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={onReply}>
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={() => onReply?.(true)}>
|
||||
<Reply {...REPLY_ICON} /> Reply all
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={onReply}>
|
||||
<button type="button" className="mail-detail-reply-btn" onClick={() => onReply?.(false)}>
|
||||
<Reply {...REPLY_ICON} /> Reply
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export default function MailRow({
|
|||
}) {
|
||||
const unread = email.is_read === false;
|
||||
const starred = email.is_starred === true;
|
||||
const messageCount = email.message_count || 1;
|
||||
const subject = email.subject?.trim() || "(No subject)";
|
||||
const snippet = email.snippet || email.preview || email.body_text || "";
|
||||
const timestamp =
|
||||
|
|
@ -111,6 +112,7 @@ export default function MailRow({
|
|||
</span>
|
||||
<span className="mailbox-subject-line">
|
||||
<span className="mailbox-subject">{subject}</span>
|
||||
{messageCount > 1 && <span className="mailbox-thread-count">{messageCount}</span>}
|
||||
{snippet && (
|
||||
<>
|
||||
<span className="mailbox-preview-sep" aria-hidden="true"> — </span>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getEmail,
|
||||
listEmails,
|
||||
getThread,
|
||||
listThreads,
|
||||
permanentlyDeleteEmail,
|
||||
restoreEmail,
|
||||
setEmailRead,
|
||||
|
|
@ -62,6 +63,41 @@ function resultPage(result) {
|
|||
};
|
||||
}
|
||||
|
||||
function threadToRow(thread) {
|
||||
const primarySender = thread.senders?.[0] || thread.participants?.[0] || { name: "Unknown", address: "" };
|
||||
return {
|
||||
id: thread.latest_message_id,
|
||||
thread_id: thread.id,
|
||||
mailbox: thread.mailbox,
|
||||
sender: primarySender,
|
||||
recipients: thread.participants || [],
|
||||
subject: thread.subject,
|
||||
snippet: thread.snippet,
|
||||
created_at: thread.latest_at,
|
||||
is_read: thread.is_read,
|
||||
is_starred: thread.is_starred,
|
||||
attachments: thread.has_attachments ? [{ filename: "attachment" }] : [],
|
||||
message_count: thread.message_count,
|
||||
unread_count: thread.unread_count,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReplyContext(message, replyAll = false) {
|
||||
const sender = message?.sender || { name: "", address: "" };
|
||||
const subject = (message?.subject || "").trim();
|
||||
const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject || "(No subject)"}`;
|
||||
const quote = (message?.body_text || message?.snippet || "").trim();
|
||||
return {
|
||||
replyToMessageId: message.id,
|
||||
replyAll,
|
||||
to: replyAll ? [] : [{ email: sender.address, name: sender.name }],
|
||||
subject: replySubject,
|
||||
bodyHtml: quote
|
||||
? `<p><br></p><blockquote>${quote.replace(/\n/g, "<br>")}</blockquote>`
|
||||
: "<p><br></p>",
|
||||
};
|
||||
}
|
||||
|
||||
export default function MailboxView({
|
||||
mailbox,
|
||||
title,
|
||||
|
|
@ -83,6 +119,7 @@ export default function MailboxView({
|
|||
const [checked, setChecked] = useState({});
|
||||
const [hidden, setHidden] = useState({});
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [threadDetail, setThreadDetail] = useState(null);
|
||||
const [status, setStatus] = useState({ loading: true, error: "" });
|
||||
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
|
@ -101,7 +138,7 @@ export default function MailboxView({
|
|||
const load = useCallback(async () => {
|
||||
setStatus({ loading: true, error: "" });
|
||||
try {
|
||||
const result = await listEmails(
|
||||
const result = await listThreads(
|
||||
{
|
||||
mailbox,
|
||||
limit: PAGE_SIZE,
|
||||
|
|
@ -115,7 +152,7 @@ export default function MailboxView({
|
|||
token,
|
||||
);
|
||||
const page = resultPage(result);
|
||||
setEmails(page.items);
|
||||
setEmails(page.items.map(threadToRow));
|
||||
setNextCursor(page.nextCursor);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
|
|
@ -193,17 +230,26 @@ export default function MailboxView({
|
|||
}
|
||||
|
||||
async function openEmail(email) {
|
||||
setSelected(email.id);
|
||||
setSelected(email.thread_id || email.id);
|
||||
setDetail(email);
|
||||
setThreadDetail(null);
|
||||
setDetailStatus({ loading: true, error: "", busy: false });
|
||||
if (email.is_read === false) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
|
||||
current.map((item) => (
|
||||
item.thread_id === email.thread_id || item.id === email.id
|
||||
? { ...item, is_read: true }
|
||||
: item
|
||||
)),
|
||||
);
|
||||
setDetail((current) => ({ ...current, is_read: true }));
|
||||
setEmailRead(email.id, true, token).catch(() => {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
|
||||
current.map((item) => (
|
||||
item.thread_id === email.thread_id || item.id === email.id
|
||||
? { ...item, is_read: false }
|
||||
: item
|
||||
)),
|
||||
);
|
||||
setDetail((current) =>
|
||||
current?.id === email.id ? { ...current, is_read: false } : current,
|
||||
|
|
@ -211,8 +257,24 @@ export default function MailboxView({
|
|||
});
|
||||
}
|
||||
try {
|
||||
const completeEmail = await getEmail(email.id, token);
|
||||
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
|
||||
if (email.thread_id) {
|
||||
const thread = await getThread(email.thread_id, token);
|
||||
const messages = thread.messages || [];
|
||||
const latest = messages[messages.length - 1] || email;
|
||||
setThreadDetail(thread);
|
||||
setDetail((current) => ({
|
||||
...current,
|
||||
...latest,
|
||||
is_read: current?.is_read ?? latest.is_read,
|
||||
}));
|
||||
} else {
|
||||
const completeEmail = await getEmail(email.id, token);
|
||||
setDetail((current) => ({
|
||||
...current,
|
||||
...completeEmail,
|
||||
is_read: current?.is_read ?? completeEmail.is_read,
|
||||
}));
|
||||
}
|
||||
setDetailStatus({ loading: false, error: "", busy: false });
|
||||
} catch (error) {
|
||||
setDetailStatus({ loading: false, error: error.message, busy: false });
|
||||
|
|
@ -694,13 +756,14 @@ export default function MailboxView({
|
|||
|
||||
<MailDetail
|
||||
email={detail}
|
||||
thread={threadDetail}
|
||||
messages={threadDetail?.messages}
|
||||
loading={detailStatus.loading}
|
||||
error={detailStatus.error}
|
||||
busy={detailStatus.busy}
|
||||
mailbox={mailbox}
|
||||
viewerEmail={account?.email}
|
||||
threadSize={1}
|
||||
onClose={() => { setSelected(null); setDetail(null); }}
|
||||
onClose={() => { setSelected(null); setDetail(null); setThreadDetail(null); }}
|
||||
onToggleRead={toggleRead}
|
||||
onToggleStar={(starred) => detail && toggleStar(detail, starred)}
|
||||
onTrash={() => mutateSelected(trashEmail)}
|
||||
|
|
@ -710,8 +773,17 @@ export default function MailboxView({
|
|||
onSpam={() => detail && hideForSession(detail, "Marked as spam in this view until you refresh.")}
|
||||
onSnooze={() => detail && hideForSession(detail, "Snoozed in this view until you refresh.")}
|
||||
onMove={() => setAgentFocus("Move and labels — coming soon.")}
|
||||
onReply={() => { onCompose?.(); setAgentFocus(detail?.subject ? `Reply: ${detail.subject}` : "Reply"); }}
|
||||
onForward={() => { onCompose?.(); setAgentFocus(detail?.subject ? `Forward: ${detail.subject}` : "Forward"); }}
|
||||
onReply={(replyAll = false) => {
|
||||
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
||||
onCompose?.(buildReplyContext(target, replyAll));
|
||||
}}
|
||||
onForward={() => {
|
||||
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
||||
onCompose?.({
|
||||
subject: `Fwd: ${target?.subject || ""}`,
|
||||
bodyHtml: `<p><br></p><blockquote>${(target?.body_text || target?.snippet || "").replace(/\n/g, "<br>")}</blockquote>`,
|
||||
});
|
||||
}}
|
||||
onExtractActions={() => detail && extractActions(detail)}
|
||||
onSummarizeThread={() => detail && summarizeThread(detail)}
|
||||
onAnalyzeAttachment={() => detail && analyzeAttachment(detail)}
|
||||
|
|
@ -725,6 +797,7 @@ export default function MailboxView({
|
|||
account={account}
|
||||
emails={listedEmails}
|
||||
contextEmail={detail}
|
||||
contextThread={threadDetail}
|
||||
focus={agentFocus}
|
||||
insight={insight}
|
||||
loading={status.loading}
|
||||
|
|
|
|||
|
|
@ -527,6 +527,127 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.security-toggle span { position:absolute; inset:0; border-radius:999px; background:#D9D5E0; transition:background .2s; cursor:pointer; }
|
||||
.security-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(20,18,30,0.18); transition:transform .2s; }
|
||||
.security-toggle input:checked + span { background:#7357F6; }
|
||||
.human-handoff-page { max-width:920px; margin:0 auto; padding:8px 4px 32px; }
|
||||
.human-handoff-head { margin-bottom:20px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
||||
.human-handoff-head h1 { margin:0 0 8px; font:700 28px/1.15 "Manrope"; color:#171424; }
|
||||
.human-handoff-head p { margin:0; max-width:640px; color:#6f6b7c; font-size:13px; line-height:1.6; }
|
||||
.human-handoff-tabs { margin-top:16px; display:inline-flex; gap:6px; padding:4px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
|
||||
.human-handoff-tabs button { height:32px; padding:0 14px; border:0; border-radius:8px; background:transparent; color:#6f6b7c; font-size:12px; font-weight:600; }
|
||||
.human-handoff-tabs button.active { background:#fff; color:#171424; box-shadow:0 1px 4px rgba(20,18,30,0.08); }
|
||||
.handoff-inbox-lead { margin:0 0 14px; color:#77758a; font-size:12px; }
|
||||
.handoff-inbox-list { display:grid; gap:10px; }
|
||||
.handoff-inbox-row { width:100%; padding:16px 18px; display:grid; grid-template-columns:1fr auto; gap:16px; align-items:start; border:1px solid #ece9f2; border-radius:12px; background:#fff; text-align:left; cursor:pointer; box-shadow:0 6px 20px rgba(20,18,30,0.03); }
|
||||
.handoff-inbox-row:hover { border-color:#ddd4ff; background:#fcfbff; }
|
||||
.handoff-inbox-row-main strong { display:block; margin-bottom:4px; font:650 14px/1.3 "DM Sans"; color:#171424; }
|
||||
.handoff-inbox-row-party { color:#6d5bd0; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.3px; }
|
||||
.handoff-inbox-row-snippet { margin:8px 0 0; color:#5a5562; font-size:12px; line-height:1.45; }
|
||||
.handoff-inbox-row-meta { min-width:120px; display:grid; gap:4px; justify-items:end; color:#77758a; font-size:11px; }
|
||||
.handoff-inbox-status { height:22px; padding:0 8px; display:inline-flex; align-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
||||
.handoff-inbox-status.status-waiting_for_human { background:#fff6e8; color:#b26a00; }
|
||||
.handoff-inbox-status.status-human_replied { background:#edf4ff; color:#2f67c5; }
|
||||
.handoff-inbox-status.status-agent_replied { background:#edf9f4; color:#11966a; }
|
||||
.handoff-back-btn { margin-bottom:14px; height:32px; padding:0 10px; display:inline-flex; align-items:center; gap:6px; border:0; border-radius:8px; background:transparent; color:#6d5bd0; font-size:12px; font-weight:600; }
|
||||
.handoff-back-btn:hover { background:#f6f2ff; }
|
||||
.handoff-conversation-head h2 { margin:0 0 10px; font:700 22px/1.2 "Manrope"; color:#171424; }
|
||||
.handoff-linked-external { padding:12px 14px; display:flex; flex-wrap:wrap; align-items:center; gap:8px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; color:#5a5562; font-size:12px; }
|
||||
.handoff-linked-external strong { color:#171424; }
|
||||
.handoff-linked-external em { color:#77758a; }
|
||||
.handoff-message-list { margin-top:18px; display:grid; gap:14px; }
|
||||
.handoff-message { padding:16px 18px; border:1px solid #ece9f2; border-radius:12px; background:#fff; }
|
||||
.handoff-message header { margin-bottom:10px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.handoff-message header strong { font-size:10px; font-weight:700; letter-spacing:.35px; color:#6d5bd0; }
|
||||
.handoff-message-human header strong { color:#11966a; }
|
||||
.handoff-message header time { color:#9a96a5; font-size:11px; }
|
||||
.handoff-message-body { color:#292536; font-size:13px; line-height:1.6; white-space:pre-wrap; }
|
||||
.handoff-settings-intro { margin-bottom:18px; padding:18px 20px; border:1px solid #ece7ff; border-radius:14px; background:#fbf9ff; }
|
||||
.handoff-settings-intro p { margin:0 0 10px; color:#4a4652; font-size:12px; line-height:1.6; }
|
||||
.handoff-settings-intro p strong { color:#171424; }
|
||||
.handoff-settings-intro small { color:#77758a; font-size:11px; line-height:1.5; }
|
||||
.handoff-flow-diagram { margin:14px 0 10px; padding:14px; display:grid; justify-items:center; gap:4px; border:1px dashed #ddd4ff; border-radius:10px; background:#fff; color:#4a4652; font-size:12px; font-weight:600; }
|
||||
.handoff-flow-arrow { color:#9a96a5; font-weight:400; }
|
||||
.handoff-settings-toolbar { margin-bottom:14px; display:flex; justify-content:flex-end; }
|
||||
.human-handoff-empty,.human-handoff-list { margin-top:8px; }
|
||||
.human-handoff-empty { padding:40px 28px; display:grid; justify-items:center; text-align:center; border:1px solid #ece9f2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); }
|
||||
.human-handoff-empty-icon { width:52px; height:52px; margin-bottom:14px; display:grid; place-items:center; border-radius:14px; background:#f6f2ff; color:#7357f6; }
|
||||
.human-handoff-empty h2 { margin:0 0 8px; font:700 18px/1.2 "DM Sans"; }
|
||||
.human-handoff-empty p { margin:0 0 18px; max-width:420px; color:#77758a; font-size:12px; line-height:1.55; }
|
||||
.human-handoff-list { display:grid; gap:12px; }
|
||||
.human-handoff-card { padding:18px 20px; border:1px solid #ece9f2; border-radius:14px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,0.035); }
|
||||
.human-handoff-card-top { display:grid; grid-template-columns:auto 1fr auto; gap:12px; align-items:center; }
|
||||
.human-handoff-card-avatar { width:38px; height:38px; display:grid; place-items:center; border-radius:50%; background:#f6f2ff; color:#7357f6; }
|
||||
.human-handoff-card-meta strong { display:block; font:650 14px/1.3 "DM Sans"; color:#171424; }
|
||||
.human-handoff-card-meta small { color:#77758a; font-size:11px; }
|
||||
.human-handoff-status { height:22px; padding:0 8px; display:inline-flex; align-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
||||
.human-handoff-status.active { background:#edf9f4; color:#11966a; }
|
||||
.human-handoff-card-summary { margin:12px 0 8px; color:#5a5562; font-size:12px; }
|
||||
.human-handoff-card-quote { margin:0 0 14px; padding-left:12px; border-left:2px solid #ece7ff; color:#4a4652; font-size:12px; line-height:1.55; }
|
||||
.human-handoff-card-actions { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
|
||||
.handoff-card-menu-wrap { position:relative; }
|
||||
.handoff-card-menu { position:absolute; right:0; top:calc(100% + 4px); min-width:160px; padding:6px; display:grid; gap:2px; border:1px solid #ece9f2; border-radius:10px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,0.12); z-index:5; }
|
||||
.handoff-card-menu button { width:100%; min-height:34px; padding:0 10px; border:0; border-radius:8px; background:transparent; text-align:left; font-size:12px; font-weight:600; color:#4a4652; }
|
||||
.handoff-card-menu button:hover { background:#f7f5fa; }
|
||||
.handoff-btn { height:36px; padding:0 14px; display:inline-flex; align-items:center; justify-content:center; gap:7px; border:1px solid #dfdde5; border-radius:9px; background:#fff; color:#241f2b; font-size:12px; font-weight:600; }
|
||||
.handoff-btn.primary { border-color:#6d5bd0; background:#7357f6; color:#fff; }
|
||||
.handoff-btn.subtle { color:#5a5562; background:#fafafa; }
|
||||
.handoff-btn.icon { width:36px; padding:0; }
|
||||
.handoff-btn:disabled { opacity:.55; cursor:not-allowed; }
|
||||
.handoff-drawer-backdrop { position:fixed; inset:0; z-index:60; display:flex; justify-content:flex-end; background:rgba(20,18,30,0.28); }
|
||||
.handoff-drawer,.handoff-test-modal { width:min(560px,100vw); height:100%; display:flex; flex-direction:column; background:#fff; box-shadow:-12px 0 40px rgba(20,18,30,0.12); }
|
||||
.handoff-test-modal { height:auto; max-height:min(720px,92vh); margin:auto 0; border-radius:16px 0 0 16px; }
|
||||
.handoff-drawer-head { padding:22px 22px 12px; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; border-bottom:1px solid #f0eef2; }
|
||||
.handoff-drawer-head h2 { margin:0 0 6px; font:700 20px/1.2 "DM Sans"; color:#171424; }
|
||||
.handoff-drawer-head p { margin:0; color:#77758a; font-size:12px; line-height:1.5; }
|
||||
.handoff-drawer-close { width:32px; height:32px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#6f6b7c; }
|
||||
.handoff-drawer-close:hover { background:#f5f4f2; }
|
||||
.handoff-drawer-body { flex:1; overflow:auto; padding:18px 22px 24px; display:grid; gap:22px; }
|
||||
.handoff-drawer-foot { padding:14px 22px 18px; display:flex; justify-content:flex-end; gap:8px; border-top:1px solid #f0eef2; background:#fcfbfd; }
|
||||
.handoff-form-section h3 { margin:0 0 8px; font:700 13px/1.3 "DM Sans"; color:#171424; text-transform:none; letter-spacing:0; }
|
||||
.handoff-field-label { display:block; margin-bottom:6px; font-size:11px; font-weight:600; color:#5a5562; }
|
||||
.handoff-helper { margin:8px 0 0; color:#77758a; font-size:11px; line-height:1.5; }
|
||||
.handoff-email-input,.handoff-text-input { width:100%; min-height:42px; padding:0 12px; display:flex; align-items:center; gap:8px; border:1px solid #e4e1e8; border-radius:10px; background:#fff; }
|
||||
.handoff-email-input input,.handoff-text-input { width:100%; border:0; outline:0; background:transparent; font-size:13px; color:#171424; }
|
||||
.handoff-owner-preview { margin-top:10px; padding:10px 12px; display:flex; align-items:center; gap:10px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
|
||||
.handoff-owner-avatar { width:34px; height:34px; display:grid; place-items:center; border-radius:50%; background:#f6f2ff; color:#7357f6; }
|
||||
.handoff-owner-preview strong { display:block; font-size:13px; }
|
||||
.handoff-owner-preview small { color:#77758a; font-size:11px; }
|
||||
.handoff-label-toolbar { margin:10px 0; display:flex; gap:8px; }
|
||||
.handoff-label-toolbar input { flex:1; min-height:34px; padding:0 10px; border:1px solid #e4e1e8; border-radius:8px; font-size:12px; }
|
||||
.handoff-label-toolbar button { height:34px; padding:0 10px; border:1px solid #e4e1e8; border-radius:8px; background:#fff; font-size:11px; font-weight:600; color:#5a5562; }
|
||||
.handoff-label-grid { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.handoff-check-pill { height:30px; padding:0 10px; display:inline-flex; align-items:center; gap:6px; border:1px solid #e4e1e8; border-radius:999px; background:#fff; font-size:11px; font-weight:600; color:#4a4652; cursor:pointer; }
|
||||
.handoff-check-pill input { accent-color:#7357f6; }
|
||||
.handoff-type-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.handoff-type-card { min-height:92px; padding:12px; display:grid; gap:4px; border:1px solid #e4e1e8; border-radius:12px; background:#fff; cursor:pointer; }
|
||||
.handoff-type-card.selected { border-color:#cfc5ff; background:#fbf9ff; box-shadow:inset 0 0 0 1px #ddd4ff; }
|
||||
.handoff-type-card input { position:absolute; opacity:0; }
|
||||
.handoff-type-card strong { font-size:12px; color:#171424; }
|
||||
.handoff-type-card span { color:#77758a; font-size:10.5px; line-height:1.45; }
|
||||
.handoff-instructions { width:100%; padding:12px; border:1px solid #e4e1e8; border-radius:12px; resize:vertical; font:400 13px/1.55 "DM Sans"; color:#292536; }
|
||||
.handoff-examples { margin-top:10px; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
||||
.handoff-examples span { color:#77758a; font-size:11px; }
|
||||
.handoff-examples button { height:28px; padding:0 10px; border:1px solid #ece7ff; border-radius:999px; background:#fbf9ff; color:#6d5bd0; font-size:10.5px; font-weight:600; }
|
||||
.handoff-summary-card { padding:14px; border:1px solid #ece7ff; border-radius:12px; background:#fbf9ff; }
|
||||
.handoff-summary-title { margin-bottom:8px; display:flex; align-items:center; gap:6px; color:#6d5bd0; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.35px; }
|
||||
.handoff-summary-card p { margin:0; color:#4a4652; font-size:12px; line-height:1.55; }
|
||||
.handoff-switch { display:flex; align-items:center; justify-content:space-between; gap:12px; font-size:13px; font-weight:600; color:#171424; }
|
||||
.handoff-switch input { position:absolute; opacity:0; width:0; height:0; }
|
||||
.handoff-switch-track { position:relative; width:44px; height:24px; border-radius:999px; background:#d9d5e0; }
|
||||
.handoff-switch-track::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; transition:transform .2s; box-shadow:0 1px 3px rgba(20,18,30,0.18); }
|
||||
.handoff-switch input:checked + .handoff-switch-track { background:#7357f6; }
|
||||
.handoff-switch input:checked + .handoff-switch-track::after { transform:translateX(20px); }
|
||||
.handoff-reply-info { margin-top:12px; padding:12px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
|
||||
.handoff-reply-info strong { display:block; margin-bottom:6px; font-size:12px; }
|
||||
.handoff-reply-info p { margin:0; color:#5a5562; font-size:11px; line-height:1.5; }
|
||||
.handoff-reply-as { margin-top:12px; display:grid; gap:8px; }
|
||||
.handoff-reply-as strong { font-size:12px; color:#171424; }
|
||||
.handoff-reply-as label { display:flex; align-items:center; gap:8px; font-size:12px; color:#4a4652; }
|
||||
.handoff-test-result { margin-top:14px; padding:14px; border:1px solid #ece9f2; border-radius:12px; background:#faf9fc; }
|
||||
.handoff-test-result h3 { margin:0 0 10px; font-size:13px; }
|
||||
.handoff-test-result ul { margin:0 0 12px; padding:0; list-style:none; display:grid; gap:6px; }
|
||||
.handoff-test-result li { font-size:12px; }
|
||||
.handoff-test-result li.pass { color:#11966a; }
|
||||
.handoff-test-result li.fail { color:#c45a4a; }
|
||||
.handoff-test-result p { margin:0 0 6px; font-size:12px; line-height:1.5; color:#4a4652; }
|
||||
.security-toggle input:checked + span::after { transform:translateX(20px); }
|
||||
.security-toggle input:disabled + span { opacity:.55; cursor:not-allowed; }
|
||||
.security-timeout-select,.admin-role-select { min-width:140px; height:40px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#4a4652; }
|
||||
|
|
@ -714,6 +835,17 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.mail-detail-reply-btn { height:36px; padding:0 14px; display:inline-flex; align-items:center; gap:7px; border:1px solid #dfdde5; border-radius:8px; background:#fff; color:#241f2b; font-size:11.5px; font-weight:600; }
|
||||
.mail-detail-reply-btn:hover { background:#f8f7f5; border-color:#d5d2da; }
|
||||
.mail-detail-reply-btn.subtle { color:#5a5562; background:#fafafa; }
|
||||
.mail-thread-messages.is-thread { padding:0 var(--reader-x0); }
|
||||
.mail-thread-message { padding:18px 0; border-top:1px solid #f0eef2; }
|
||||
.mail-thread-message:first-child { border-top:0; }
|
||||
.mail-thread-message-collapsed { width:100%; padding:12px 0; display:grid; grid-template-columns:38px minmax(0,1fr) auto; gap:12px; align-items:center; border:0; border-top:1px solid #f0eef2; background:transparent; text-align:left; }
|
||||
.mail-thread-message-collapsed:first-child { border-top:0; }
|
||||
.mail-thread-message-collapsed:hover { background:#faf9fc; }
|
||||
.mail-thread-message-collapsed-main { min-width:0; display:grid; gap:2px; }
|
||||
.mail-thread-message-collapsed-main strong { font:600 12px/1.3 "DM Sans"; color:#171424; }
|
||||
.mail-thread-message-collapsed-main span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#77758a; font-size:11px; }
|
||||
.mail-thread-message-collapsed time { color:#77758a; font-size:11px; white-space:nowrap; }
|
||||
.mailbox-thread-count { min-width:18px; height:18px; padding:0 6px; display:inline-grid; place-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
||||
.agent-panel.email-context .agent-stat-grid,
|
||||
.agent-panel.email-context .agent-context-block,
|
||||
.agent-panel.email-context .agent-quick { display:none; }
|
||||
|
|
|
|||
71
frontend/src/utils/handoffUtils.js
Normal file
71
frontend/src/utils/handoffUtils.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export const HANDOFF_LABELS = ["Important", "Finance", "Clients", "Projects", "Personal", "Support"];
|
||||
|
||||
export const EMAIL_TYPE_OPTIONS = [
|
||||
{
|
||||
id: "utility",
|
||||
title: "Utility",
|
||||
description: "Account updates, alerts and service information",
|
||||
},
|
||||
{
|
||||
id: "transactional",
|
||||
title: "Transactional",
|
||||
description: "Orders, payments, invoices, receipts and account activity",
|
||||
},
|
||||
{
|
||||
id: "promotional",
|
||||
title: "Promotional",
|
||||
description: "Offers, deals and promotional messages",
|
||||
},
|
||||
{
|
||||
id: "marketing",
|
||||
title: "Marketing",
|
||||
description: "Campaigns, newsletters and marketing outreach",
|
||||
},
|
||||
];
|
||||
|
||||
export const INSTRUCTION_EXAMPLES = [
|
||||
"Refund requests",
|
||||
"Human requested",
|
||||
"Approval needed",
|
||||
];
|
||||
|
||||
function joinOrNone(items, fallback = "any email") {
|
||||
if (!items?.length) return fallback;
|
||||
if (items.length === 1) return items[0];
|
||||
if (items.length === 2) return `${items[0]} or ${items[1]}`;
|
||||
return `${items.slice(0, -1).join(", ")} or ${items.at(-1)}`;
|
||||
}
|
||||
|
||||
function typeTitles(types) {
|
||||
return types.map((id) => EMAIL_TYPE_OPTIONS.find((item) => item.id === id)?.title || id);
|
||||
}
|
||||
|
||||
export function buildHandoffSummary({
|
||||
ownerEmail,
|
||||
labels,
|
||||
emailTypes,
|
||||
instructions,
|
||||
}) {
|
||||
const labelText = labels?.length
|
||||
? `${joinOrNone(labels)} emails`
|
||||
: "eligible emails";
|
||||
const typeText = emailTypes?.length
|
||||
? typeTitles(emailTypes).join(" or ").toLowerCase()
|
||||
: "any type";
|
||||
const instructionText = instructions?.trim()
|
||||
? "when they require your personal decision or attention"
|
||||
: "when your handoff instructions apply";
|
||||
|
||||
return `Your agent will handle email normally. ${labelText} that are ${typeText} will be handed to ${ownerEmail || "the human owner"} ${instructionText}.`;
|
||||
}
|
||||
|
||||
export function buildRuleCardSummary(rule) {
|
||||
const parts = [];
|
||||
if (rule.labels?.length) parts.push(rule.labels.join(", "));
|
||||
if (rule.email_types?.length) {
|
||||
parts.push(typeTitles(rule.email_types).join(", "));
|
||||
}
|
||||
const meta = parts.length ? parts.join(" • ") : "All eligible emails";
|
||||
const replies = rule.allow_replies ? "Replies enabled" : "View only";
|
||||
return `${meta} • ${replies}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue