human handoffs
This commit is contained in:
parent
79188e0f59
commit
36a55930f9
35 changed files with 4039 additions and 139 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue