human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
314
backend/app/routers/threads.py
Normal file
314
backend/app/routers/threads.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.database import get_mongo_database
|
||||
from app.models import Account
|
||||
from app.routers.auth import get_current_account
|
||||
from app.routers.emails import decode_cursor, encode_cursor, summarize
|
||||
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.raw_mime_storage import get_raw_mime_storage
|
||||
|
||||
router = APIRouter(prefix="/threads", tags=["threads"])
|
||||
|
||||
|
||||
def encode_thread_cursor(thread: dict) -> str:
|
||||
value = json.dumps(
|
||||
{
|
||||
"latest_at": thread["latest_at"].isoformat(),
|
||||
"id": str(thread["_id"]),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_thread_cursor(value: str) -> tuple[datetime, ObjectId]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return datetime.fromisoformat(payload["latest_at"]), ObjectId(payload["id"])
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid cursor.",
|
||||
) from error
|
||||
|
||||
|
||||
def participant_key(address: dict) -> str:
|
||||
return address.get("address", "").lower()
|
||||
|
||||
|
||||
def unique_addresses(entries: list[dict]) -> list[dict]:
|
||||
seen: set[str] = set()
|
||||
values: list[dict] = []
|
||||
for entry in entries:
|
||||
for key in ("sender",):
|
||||
address = entry.get(key)
|
||||
if isinstance(address, dict):
|
||||
token = participant_key(address)
|
||||
if token and token not in seen:
|
||||
seen.add(token)
|
||||
values.append(address)
|
||||
return values
|
||||
|
||||
|
||||
def thread_summary_from_group(row: dict, mailbox: str) -> ThreadSummary:
|
||||
latest_entry = row["latest_entry"]
|
||||
latest_message = latest_entry["message"]
|
||||
senders = unique_addresses(row.get("entries", [latest_entry]))
|
||||
participants = senders[:]
|
||||
for entry in row.get("entries", []):
|
||||
for recipient in entry.get("recipients", []):
|
||||
token = participant_key(recipient)
|
||||
if token and token not in {participant_key(item) for item in participants}:
|
||||
participants.append(recipient)
|
||||
latest_at = latest_entry.get("event_at") or latest_message.get("received_at") or latest_message["created_at"]
|
||||
return ThreadSummary(
|
||||
id=str(row["_id"]),
|
||||
mailbox=mailbox,
|
||||
subject=latest_message.get("subject", ""),
|
||||
snippet=latest_message.get("snippet", ""),
|
||||
participants=[{"name": item.get("name", ""), "address": item.get("address", "")} for item in participants[:6]],
|
||||
message_count=row.get("message_count", 1),
|
||||
unread_count=row.get("unread_count", 0),
|
||||
is_read=row.get("unread_count", 0) == 0,
|
||||
is_starred=bool(row.get("is_starred")),
|
||||
has_attachments=bool(row.get("has_attachments")),
|
||||
latest_message_id=str(latest_message["_id"]),
|
||||
latest_at=latest_at,
|
||||
senders=[{"name": item.get("name", ""), "address": item.get("address", "")} for item in senders[:4]],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ThreadPage)
|
||||
async def list_threads(
|
||||
mailbox: Annotated[str, Query(pattern="^(inbox|sent|trash)$")] = "inbox",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
||||
cursor: str | None = None,
|
||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
||||
is_read: bool | None = None,
|
||||
sender: Annotated[str | None, Query(max_length=320)] = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
entry_match: dict = {
|
||||
"account_id": str(account.id),
|
||||
"deletion_state": {"$in": [None, ""]},
|
||||
"thread_id": {"$ne": None},
|
||||
}
|
||||
if mailbox == "trash":
|
||||
entry_match["trashed_at"] = {"$ne": None}
|
||||
else:
|
||||
entry_match.update({"mailbox": mailbox, "trashed_at": None})
|
||||
if sender:
|
||||
pattern = f"^{re.escape(sender.strip())}"
|
||||
entry_match["$or"] = [
|
||||
{"sender.name": {"$regex": pattern, "$options": "i"}},
|
||||
{"sender.address": {"$regex": pattern, "$options": "i"}},
|
||||
]
|
||||
if date_from or date_to:
|
||||
event_range: dict = {}
|
||||
if date_from:
|
||||
event_range["$gte"] = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
||||
if date_to:
|
||||
event_range["$lt"] = datetime.combine(
|
||||
date_to + timedelta(days=1),
|
||||
time.min,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
entry_match["event_at"] = event_range
|
||||
apply_external_scope(entry_match)
|
||||
|
||||
pipeline: list[dict] = [
|
||||
{"$match": entry_match},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
if q and q.strip():
|
||||
pipeline.append(
|
||||
{
|
||||
"$match": {
|
||||
"$or": [
|
||||
{"subject": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
{"snippet": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
{"message.subject": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
{"message.snippet": {"$regex": re.escape(q.strip()), "$options": "i"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
pipeline.extend(
|
||||
[
|
||||
{
|
||||
"$group": {
|
||||
"_id": "$thread_id",
|
||||
"entries": {"$push": "$$ROOT"},
|
||||
"latest_at": {"$max": "$event_at"},
|
||||
"unread_count": {
|
||||
"$sum": {"$cond": [{"$eq": ["$is_read", False]}, 1, 0]}
|
||||
},
|
||||
"is_starred": {"$max": "$is_starred"},
|
||||
"has_attachments": {
|
||||
"$max": {
|
||||
"$gt": [
|
||||
{"$size": {"$ifNull": ["$attachments", []]}},
|
||||
0,
|
||||
]
|
||||
}
|
||||
},
|
||||
"message_count": {"$sum": 1},
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
if is_read is not None:
|
||||
pipeline.append(
|
||||
{
|
||||
"$match": {
|
||||
"unread_count": 0 if is_read else {"$gt": 0},
|
||||
}
|
||||
}
|
||||
)
|
||||
pipeline.append({"$sort": {"latest_at": -1, "_id": -1}})
|
||||
if cursor:
|
||||
latest_at, thread_id = decode_thread_cursor(cursor)
|
||||
pipeline.append(
|
||||
{
|
||||
"$match": {
|
||||
"$or": [
|
||||
{"latest_at": {"$lt": latest_at}},
|
||||
{"latest_at": latest_at, "_id": {"$lt": thread_id}},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
pipeline.append({"$limit": limit + 1})
|
||||
pipeline.append(
|
||||
{
|
||||
"$addFields": {
|
||||
"latest_entry": {
|
||||
"$first": {
|
||||
"$sortArray": {
|
||||
"input": "$entries",
|
||||
"sortBy": {"event_at": -1, "_id": -1},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
items = [thread_summary_from_group(row, mailbox) for row in rows]
|
||||
next_cursor = None
|
||||
if has_more and rows:
|
||||
next_cursor = encode_thread_cursor(
|
||||
{"latest_at": rows[-1]["latest_at"], "_id": rows[-1]["_id"]}
|
||||
)
|
||||
return ThreadPage(items=items, next_cursor=next_cursor)
|
||||
|
||||
|
||||
async def message_read(
|
||||
database,
|
||||
message: dict,
|
||||
entry: dict,
|
||||
account: Account,
|
||||
) -> MessageRead:
|
||||
body_text = message.get("body_text", "")
|
||||
body_html = message.get("body_html")
|
||||
if message.get("raw_mime"):
|
||||
raw = await get_raw_mime_storage().get(message["raw_mime"])
|
||||
parsed = parse_mime(raw)
|
||||
body_text = parsed["body_text"]
|
||||
body_html = parsed["body_html"]
|
||||
summary = summarize(message, entry)
|
||||
return MessageRead(
|
||||
**summary.model_dump(),
|
||||
cc=message.get("cc", []),
|
||||
bcc=message.get("bcc", []) if message.get("account_id") == str(account.id) else [],
|
||||
reply_to=message.get("reply_to", []),
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
message_id=message.get("message_id"),
|
||||
in_reply_to=message.get("in_reply_to"),
|
||||
references=message.get("references", []),
|
||||
parent_message_id=str(message["parent_message_id"])
|
||||
if message.get("parent_message_id")
|
||||
else None,
|
||||
thread_relation=message.get("thread_relation"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{thread_id}", response_model=ThreadRead)
|
||||
async def get_thread(
|
||||
thread_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
if not ObjectId.is_valid(thread_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid thread ID.",
|
||||
)
|
||||
thread_object_id = ObjectId(thread_id)
|
||||
thread = await database.email_threads.find_one(
|
||||
{"_id": thread_object_id, "account_id": str(account.id)}
|
||||
)
|
||||
if thread is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thread not found.")
|
||||
if thread.get("thread_type") == "HUMAN_HANDOFF":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thread not found.")
|
||||
|
||||
entry_match = {
|
||||
"account_id": str(account.id),
|
||||
"thread_id": thread_object_id,
|
||||
"deletion_state": {"$in": [None, ""]},
|
||||
}
|
||||
apply_external_scope(entry_match)
|
||||
pipeline = [
|
||||
{
|
||||
"$match": entry_match,
|
||||
},
|
||||
{"$sort": {"event_at": 1, "_id": 1}},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
rows = [row async for row in database.mailbox_entries.aggregate(pipeline)]
|
||||
if not rows:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thread not found.")
|
||||
|
||||
messages = [
|
||||
await message_read(database, row["message"], row, account)
|
||||
for row in rows
|
||||
]
|
||||
return ThreadRead(
|
||||
thread_id=thread_id,
|
||||
subject=messages[-1].subject if messages else "",
|
||||
message_count=len(messages),
|
||||
messages=messages,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue