agentic.email/backend/app/routers/threads.py
2026-10-06 04:42:47 +05:30

323 lines
11 KiB
Python

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.reply_extractor import reply_fields_for_message
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},
}
}
}
}
}
)
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
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)
reply_fields = reply_fields_for_message(
message,
body_text=body_text,
body_html=body_html,
)
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,
**reply_fields,
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"},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
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,
)