316 lines
11 KiB
Python
316 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.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)
|
|
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"},
|
|
]
|
|
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,
|
|
)
|