Files
simbarag/blueprints/email/__init__.py
T
ryan 395c97958f Fix history roles and persist tool results for cross-turn context
Two fixes for the agent losing track of conversation context:

- History replay compared msg.speaker (a Speaker enum) to the string
  "user", which is always false, so every prior message — including the
  user's — was sent to the LLM with role "assistant". Compare against
  Speaker.USER instead. Affected web, iMessage, WhatsApp, and email.

- Tool outputs (web searches, RAG chunks) were discarded after each
  turn, so follow-up questions about fetched content had nothing to
  reference. Persist them as speaker="tool" messages (capped at 4000
  chars) and replay them in history as assistant context. The
  conversation GET endpoint serves only their header line so the UI
  tool pill stays compact.

Also bumps the replayed history window from 10 to 20 messages since
tool messages now share it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 15:43:02 -04:00

233 lines
7.1 KiB
Python

import os
import hmac
import hashlib
import logging
import functools
import time
from collections import defaultdict
import httpx
from quart import Blueprint, request
from blueprints.users.models import User
from blueprints.conversation.logic import (
get_conversation_for_channel,
add_message_to_conversation,
persist_agent_tool_messages,
)
from blueprints.conversation.agents import main_agent
from blueprints.conversation.models import Speaker
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
from . import models # noqa: F401 — register Tortoise ORM models
from .helpers import generate_email_token, get_user_email_address # noqa: F401
email_blueprint = Blueprint("email_api", __name__, url_prefix="/api/email")
logger = logging.getLogger(__name__)
# Rate limiting: per-sender message timestamps
_rate_limit_store: dict[str, list[float]] = defaultdict(list)
RATE_LIMIT_MAX = int(os.getenv("EMAIL_RATE_LIMIT_MAX", "5"))
RATE_LIMIT_WINDOW = int(os.getenv("EMAIL_RATE_LIMIT_WINDOW", "300"))
MAX_MESSAGE_LENGTH = 2000
# --- Mailgun signature validation ---
def validate_mailgun_signature(f):
"""Decorator to validate Mailgun webhook signatures."""
@functools.wraps(f)
async def decorated_function(*args, **kwargs):
if os.getenv("MAILGUN_SIGNATURE_VALIDATION", "true").lower() == "false":
return await f(*args, **kwargs)
signing_key = os.getenv("MAILGUN_WEBHOOK_SIGNING_KEY")
if not signing_key:
logger.error("MAILGUN_WEBHOOK_SIGNING_KEY not set — rejecting request")
return "", 406
form_data = await request.form
timestamp = form_data.get("timestamp", "")
token = form_data.get("token", "")
signature = form_data.get("signature", "")
if not timestamp or not token or not signature:
logger.warning("Missing Mailgun signature fields")
return "", 406
expected = hmac.new(
signing_key.encode(),
f"{timestamp}{token}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
logger.warning("Invalid Mailgun signature")
return "", 406
return await f(*args, **kwargs)
return decorated_function
# --- Rate limiting ---
def _check_rate_limit(sender: str) -> bool:
"""Check if a sender has exceeded the rate limit.
Returns True if the request is allowed, False if rate-limited.
"""
now = time.monotonic()
cutoff = now - RATE_LIMIT_WINDOW
timestamps = _rate_limit_store[sender]
_rate_limit_store[sender] = [t for t in timestamps if t > cutoff]
if len(_rate_limit_store[sender]) >= RATE_LIMIT_MAX:
return False
_rate_limit_store[sender].append(now)
return True
# --- Send reply via Mailgun API ---
async def send_email_reply(
to: str, subject: str, body: str, in_reply_to: str | None = None
):
"""Send a reply email via the Mailgun API."""
api_key = os.getenv("MAILGUN_API_KEY")
domain = os.getenv("MAILGUN_DOMAIN")
if not api_key or not domain:
logger.error("MAILGUN_API_KEY or MAILGUN_DOMAIN not configured")
return
data = {
"from": f"Simba <simba@{domain}>",
"to": to,
"subject": f"Re: {subject}" if not subject.startswith("Re:") else subject,
"text": body,
}
if in_reply_to:
data["h:In-Reply-To"] = in_reply_to
async with httpx.AsyncClient() as client:
resp = await client.post(
f"https://api.mailgun.net/v3/{domain}/messages",
auth=("api", api_key),
data=data,
)
if resp.status_code != 200:
logger.error(f"Mailgun send failed ({resp.status_code}): {resp.text}")
else:
logger.info(f"Sent email reply to {to}")
# --- Webhook route ---
@email_blueprint.route("/webhook", methods=["POST"])
@validate_mailgun_signature
async def webhook():
"""Handle inbound emails forwarded by Mailgun."""
form_data = await request.form
sender = form_data.get("sender", "")
recipient = form_data.get("recipient", "")
body = form_data.get("stripped-text", "")
subject = form_data.get("subject", "(no subject)")
message_id = form_data.get("Message-Id", "")
# Extract token from recipient: ask+<token>@domain
local_part = recipient.split("@")[0] if "@" in recipient else ""
if "+" not in local_part:
logger.info(f"Ignoring email to {recipient} — no token in address")
return "", 200
token = local_part.split("+", 1)[1]
# Lookup user by token
user = await User.filter(email_hmac_token=token, email_enabled=True).first()
if not user:
logger.info(f"No user found for email token {token}")
return "", 200
# Rate limit
if not _check_rate_limit(sender):
logger.warning(f"Rate limit exceeded for email sender {sender}")
return "", 200
# Clean up body
body = (body or "").strip()
if not body:
logger.info(f"Ignoring empty email from {sender}")
return "", 200
if len(body) > MAX_MESSAGE_LENGTH:
body = body[:MAX_MESSAGE_LENGTH]
logger.info(f"Truncated long email from {sender} to {MAX_MESSAGE_LENGTH} chars")
logger.info(
f"Processing email from {sender} for user {user.username}: {body[:100]}"
)
# Get or create conversation
try:
conversation = await get_conversation_for_channel(user=user, channel="email")
await conversation.fetch_related("messages")
except Exception as e:
logger.error(f"Failed to get conversation for user {user.username}: {e}")
return "", 200
# Add user message
await add_message_to_conversation(
conversation=conversation,
message=body,
speaker="user",
user=user,
)
# Build messages payload
try:
messages = await conversation.messages.all()
recent_messages = list(messages)[-20:]
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
for msg in recent_messages[:-1]:
role = "user" if msg.speaker == Speaker.USER else "assistant"
messages_payload.append({"role": role, "content": msg.text})
messages_payload.append({"role": "user", "content": body})
logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages")
response = await main_agent.ainvoke({"messages": messages_payload})
response_text = response.get("messages", [])[-1].content
await persist_agent_tool_messages(
conversation, user, response.get("messages", [])
)
except Exception as e:
logger.error(f"Error invoking agent for email: {e}")
response_text = "Sorry, I'm having trouble thinking right now."
# Save response
await add_message_to_conversation(
conversation=conversation,
message=response_text,
speaker="simba",
user=user,
)
# Send reply email
await send_email_reply(
to=sender,
subject=subject,
body=response_text,
in_reply_to=message_id,
)
return "", 200