human handoffs

This commit is contained in:
debu-2 2026-10-06 01:51:23 +05:30
parent 79188e0f59
commit 36a55930f9
35 changed files with 4039 additions and 139 deletions

View file

@ -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)

View 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()
],
}

View file

@ -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 = [

View 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,
)

View file

@ -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

View 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,
}

View 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

View file

@ -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"

View file

@ -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"])

View 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

View 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)

View file

@ -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", [])),

View 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

View 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()

View 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

View 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"],
}

View 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

View 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

View 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"}

View 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>"]

View file

@ -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) =>

View file

@ -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) => (

View file

@ -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);
}

View file

@ -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) {

View file

@ -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"

View 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>&ldquo;{conversation.linked_external.subject}&rdquo;</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>
);
}

View 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">&ldquo;{item.snippet}&rdquo;</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>
);
}

View 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&apos;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">&ldquo;{rule.instructions}&rdquo;</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>
);
}

View 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>
);
}

View 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&apos;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&apos;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>
);
}

View file

@ -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">&lt;{sender.address}&gt;</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">&lt;{sender.address}&gt;</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>
)}

View file

@ -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>

View file

@ -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}

View file

@ -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; }

View 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}`;
}