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

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