| 1 | """ |
| 2 | Email dispatch logic — routes inbound emails to chats. |
| 3 | |
| 4 | No agent deps in pure helpers. |
| 5 | """ |
| 6 | |
| 7 | import re |
| 8 | from dataclasses import dataclass |
| 9 | from typing import Literal, TypedDict |
| 10 | |
| 11 | # Pattern for extracting chat thread ID from email subject |
| 12 | # Matches: [a0-xxxxxxxx] at end of subject |
| 13 | _THREAD_ID_RE = re.compile(r"\[a0-([a-zA-Z0-9]+)\]") |
| 14 | |
| 15 | # Context data keys (no underscore prefix — must persist across restarts) |
| 16 | CTX_EMAIL_HANDLER = "email_handler" |
| 17 | CTX_EMAIL_SENDER = "email_sender" |
| 18 | CTX_EMAIL_THREAD_ID = "email_thread_id" |
| 19 | CTX_EMAIL_SUBJECT = "email_subject" |
| 20 | CTX_EMAIL_MESSAGE_ID = "email_message_id" |
| 21 | CTX_EMAIL_REFERENCES = "email_references" |
| 22 | CTX_EMAIL_LAST_BODY = "email_last_body" |
| 23 | # Transient — consumed per-reply, not persisted |
| 24 | CTX_EMAIL_ATTACHMENTS = "_email_response_attachments" |
| 25 | |
| 26 | BODY_PREVIEW_MAX_CHARS: int = 2000 |
| 27 | |
| 28 | DispatchAction = Literal["new_chat", "continue_chat"] |
| 29 | |
| 30 | |
| 31 | @dataclass |
| 32 | class DispatchDecision: |
| 33 | action: DispatchAction |
| 34 | context_id: str = "" |
| 35 | reason: str = "" |
| 36 | |
| 37 | |
| 38 | def extract_thread_id(subject: str) -> str: |
| 39 | match = _THREAD_ID_RE.search(subject) |
| 40 | return match.group(1) if match else "" |
| 41 | |
| 42 | |
| 43 | def build_reply_subject(original_subject: str, thread_id: str) -> str: |
| 44 | clean = _THREAD_ID_RE.sub("", original_subject).strip() |
| 45 | if not clean.lower().startswith("re:"): |
| 46 | clean = f"Re: {clean}" |
| 47 | return f"{clean} [a0-{thread_id}]" |
| 48 | |
| 49 | |
| 50 | class ChatSummary(TypedDict): |
| 51 | context_id: str |
| 52 | thread_id: str |
| 53 | sender: str |
| 54 | subject: str |
| 55 | handler: str |
| 56 | history_preview: str |
| 57 | |
| 58 | |
| 59 | def build_chat_summary(context_id: str, data: dict) -> ChatSummary: |
| 60 | return { |
| 61 | "context_id": context_id, |
| 62 | "thread_id": data.get(CTX_EMAIL_THREAD_ID, ""), |
| 63 | "sender": data.get(CTX_EMAIL_SENDER, ""), |
| 64 | "subject": data.get(CTX_EMAIL_SUBJECT, ""), |
| 65 | "handler": data.get(CTX_EMAIL_HANDLER, ""), |
| 66 | "history_preview": "", |
| 67 | } |
| 68 | |
| 69 | |
| 70 | # ------------------------------------------------------------------ |
| 71 | # Dispatcher prompt builders |
| 72 | # ------------------------------------------------------------------ |
| 73 | |
| 74 | def truncate_body(body: str) -> str: |
| 75 | if len(body) <= BODY_PREVIEW_MAX_CHARS: |
| 76 | return body |
| 77 | return body[:BODY_PREVIEW_MAX_CHARS] + "... (truncated)" |
| 78 | |
| 79 | |
| 80 | def format_chats_list(existing_chats: list[ChatSummary]) -> str: |
| 81 | if not existing_chats: |
| 82 | return "No existing chats for this handler." |
| 83 | sections = [] |
| 84 | for c in existing_chats[:20]: |
| 85 | header = ( |
| 86 | f"- context_id={c['context_id']}" |
| 87 | f" sender={c.get('sender', '')} subject={c.get('subject', '')}" |
| 88 | ) |
| 89 | preview = c.get("history_preview", "") |
| 90 | if preview: |
| 91 | header += f"\n conversation:\n {preview}" |
| 92 | sections.append(header) |
| 93 | return "\n".join(sections) |
| 94 | |
| 95 | |
| 96 | def parse_dispatcher_response(response: str) -> DispatchDecision: |
| 97 | line = response.strip().split("\n")[0].strip() |
| 98 | parts = line.split(None, 2) |
| 99 | |
| 100 | if len(parts) < 2: |
| 101 | return DispatchDecision(action="new_chat", reason="unparseable response") |
| 102 | |
| 103 | action_raw = parts[0].upper() |
| 104 | ctx_id = parts[1] if parts[1] != "_" else "" |
| 105 | reason = parts[2] if len(parts) > 2 else "" |
| 106 | |
| 107 | action_map: dict[str, DispatchAction] = { |
| 108 | "NEW_CHAT": "new_chat", |
| 109 | "CONTINUE": "continue_chat", |
| 110 | } |
| 111 | action = action_map.get(action_raw, "new_chat") |
| 112 | return DispatchDecision(action=action, context_id=ctx_id, reason=reason) |