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