Add email channel via Mailgun for Ask Simba
Users can now receive a unique email address (ask+<token>@domain) and interact with Simba by sending emails. Inbound emails hit a Mailgun webhook, are authenticated via HMAC token lookup, processed through the LangChain agent, and replied to via the Mailgun API. - Extract shared SIMBA_SYSTEM_PROMPT to blueprints/conversation/prompts.py - Add email_enabled and email_hmac_token fields to User model - Create blueprints/email with webhook, signature validation, rate limiting - Add admin endpoints to enable/disable email per user - Update AdminPanel with Email column, toggle, and copy-address button - Add Mailgun env vars to .env.example - Include database migration for new fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.env.example
11
.env.example
@@ -70,6 +70,17 @@ TWILIO_SIGNATURE_VALIDATION=true
|
||||
# WHATSAPP_RATE_LIMIT_MAX=10
|
||||
# WHATSAPP_RATE_LIMIT_WINDOW=60
|
||||
|
||||
# Mailgun Configuration (Email channel)
|
||||
MAILGUN_API_KEY=
|
||||
MAILGUN_DOMAIN=
|
||||
MAILGUN_WEBHOOK_SIGNING_KEY=
|
||||
EMAIL_HMAC_SECRET=
|
||||
# Rate limiting: max emails per window (default: 5 per 300 seconds)
|
||||
# EMAIL_RATE_LIMIT_MAX=5
|
||||
# EMAIL_RATE_LIMIT_WINDOW=300
|
||||
# Set to false to disable Mailgun signature validation in development
|
||||
MAILGUN_SIGNATURE_VALIDATION=true
|
||||
|
||||
# Obsidian Configuration (headless sync)
|
||||
# Auth token from Obsidian account (Settings → Account → API token)
|
||||
OBSIDIAN_AUTH_TOKEN=your-obsidian-auth-token
|
||||
|
||||
2
app.py
2
app.py
@@ -11,6 +11,7 @@ import blueprints.conversation.logic
|
||||
import blueprints.rag
|
||||
import blueprints.users
|
||||
import blueprints.whatsapp
|
||||
import blueprints.email
|
||||
import blueprints.users.models
|
||||
from main import consult_simba_oracle
|
||||
|
||||
@@ -34,6 +35,7 @@ app.register_blueprint(blueprints.users.user_blueprint)
|
||||
app.register_blueprint(blueprints.conversation.conversation_blueprint)
|
||||
app.register_blueprint(blueprints.rag.rag_blueprint)
|
||||
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
|
||||
app.register_blueprint(blueprints.email.email_blueprint)
|
||||
|
||||
|
||||
# Database configuration with environment variable support
|
||||
|
||||
@@ -20,68 +20,13 @@ from .models import (
|
||||
PydConversation,
|
||||
PydListConversation,
|
||||
)
|
||||
from .prompts import SIMBA_SYSTEM_PROMPT
|
||||
|
||||
conversation_blueprint = Blueprint(
|
||||
"conversation_api", __name__, url_prefix="/api/conversation"
|
||||
)
|
||||
|
||||
_SYSTEM_PROMPT = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
|
||||
|
||||
SIMBA FACTS (as of January 2026):
|
||||
- Name: Simba
|
||||
- Species: Feline (Domestic Short Hair / American Short Hair)
|
||||
- Sex: Male, Neutered
|
||||
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
|
||||
- Color: Orange
|
||||
- Current Weight: 16 lbs (as of 1/8/2026)
|
||||
- Owner: Ryan Chen
|
||||
- Location: Long Island City, NY
|
||||
- Veterinarian: Court Square Animal Hospital
|
||||
|
||||
Medical Conditions:
|
||||
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
|
||||
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
|
||||
|
||||
Recent Medical Events:
|
||||
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
|
||||
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
|
||||
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
|
||||
|
||||
Diet & Lifestyle:
|
||||
- Diet: Hill's I/D wet and dry food
|
||||
- Supplements: Plaque Off
|
||||
- Indoor only cat, only pet in the household
|
||||
|
||||
Upcoming Appointments:
|
||||
- Rabies Vaccine: Due 2/19/2026
|
||||
- Routine Examination: Due 6/1/2026
|
||||
- FVRCP-3yr Vaccine: Due 10/2/2026
|
||||
|
||||
IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.
|
||||
|
||||
BUDGET & FINANCE (YNAB Integration):
|
||||
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
|
||||
- Use ynab_budget_summary for overall budget health and status questions
|
||||
- Use ynab_search_transactions to find specific purchases or spending at particular stores
|
||||
- Use ynab_category_spending to analyze spending by category for a month
|
||||
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
||||
Always use these tools when asked about budgets, spending, transactions, or financial health.
|
||||
|
||||
NOTES & RESEARCH (Obsidian Integration):
|
||||
You have access to Ryan's Obsidian vault through the Obsidian integration. When users ask about research, personal notes, or information that might be stored in markdown files, use the appropriate Obsidian tools:
|
||||
- Use obsidian_search_notes to search through your vault for relevant information
|
||||
- Use obsidian_read_note to read the full content of a specific note by path
|
||||
- Use obsidian_create_note to save new findings, ideas, or research to your vault
|
||||
- Use obsidian_create_task to create task notes with due dates
|
||||
Always use these tools when users ask about notes, research, ideas, tasks, or when you want to save information for future reference.
|
||||
|
||||
DAILY JOURNAL (Task Tracking):
|
||||
You have access to Ryan's daily journal notes. Each note lives at journal/YYYY/YYYY-MM-DD.md and has two sections: tasks and log.
|
||||
- Use journal_get_today to read today's full daily note (tasks + log)
|
||||
- Use journal_get_tasks to list tasks (done/pending) for today or a specific date
|
||||
- Use journal_add_task to add a new task to today's (or a given date's) note
|
||||
- Use journal_complete_task to check off a task as done
|
||||
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete."""
|
||||
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def _build_messages_payload(conversation, query_text: str) -> list:
|
||||
|
||||
57
blueprints/conversation/prompts.py
Normal file
57
blueprints/conversation/prompts.py
Normal file
@@ -0,0 +1,57 @@
|
||||
SIMBA_SYSTEM_PROMPT = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
|
||||
|
||||
SIMBA FACTS (as of January 2026):
|
||||
- Name: Simba
|
||||
- Species: Feline (Domestic Short Hair / American Short Hair)
|
||||
- Sex: Male, Neutered
|
||||
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
|
||||
- Color: Orange
|
||||
- Current Weight: 16 lbs (as of 1/8/2026)
|
||||
- Owner: Ryan Chen
|
||||
- Location: Long Island City, NY
|
||||
- Veterinarian: Court Square Animal Hospital
|
||||
|
||||
Medical Conditions:
|
||||
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
|
||||
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
|
||||
|
||||
Recent Medical Events:
|
||||
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
|
||||
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
|
||||
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
|
||||
|
||||
Diet & Lifestyle:
|
||||
- Diet: Hill's I/D wet and dry food
|
||||
- Supplements: Plaque Off
|
||||
- Indoor only cat, only pet in the household
|
||||
|
||||
Upcoming Appointments:
|
||||
- Rabies Vaccine: Due 2/19/2026
|
||||
- Routine Examination: Due 6/1/2026
|
||||
- FVRCP-3yr Vaccine: Due 10/2/2026
|
||||
|
||||
IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.
|
||||
|
||||
BUDGET & FINANCE (YNAB Integration):
|
||||
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
|
||||
- Use ynab_budget_summary for overall budget health and status questions
|
||||
- Use ynab_search_transactions to find specific purchases or spending at particular stores
|
||||
- Use ynab_category_spending to analyze spending by category for a month
|
||||
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
||||
Always use these tools when asked about budgets, spending, transactions, or financial health.
|
||||
|
||||
NOTES & RESEARCH (Obsidian Integration):
|
||||
You have access to Ryan's Obsidian vault through the Obsidian integration. When users ask about research, personal notes, or information that might be stored in markdown files, use the appropriate Obsidian tools:
|
||||
- Use obsidian_search_notes to search through your vault for relevant information
|
||||
- Use obsidian_read_note to read the full content of a specific note by path
|
||||
- Use obsidian_create_note to save new findings, ideas, or research to your vault
|
||||
- Use obsidian_create_task to create task notes with due dates
|
||||
Always use these tools when users ask about notes, research, ideas, tasks, or when you want to save information for future reference.
|
||||
|
||||
DAILY JOURNAL (Task Tracking):
|
||||
You have access to Ryan's daily journal notes. Each note lives at journal/YYYY/YYYY-MM-DD.md and has two sections: tasks and log.
|
||||
- Use journal_get_today to read today's full daily note (tasks + log)
|
||||
- Use journal_get_tasks to list tasks (done/pending) for today or a specific date
|
||||
- Use journal_add_task to add a new task to today's (or a given date's) note
|
||||
- Use journal_complete_task to check off a task as done
|
||||
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete."""
|
||||
217
blueprints/email/__init__.py
Normal file
217
blueprints/email/__init__.py
Normal file
@@ -0,0 +1,217 @@
|
||||
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_user,
|
||||
add_message_to_conversation,
|
||||
get_conversation_transcript,
|
||||
)
|
||||
from blueprints.conversation.agents import main_agent
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
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_user(user=user)
|
||||
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)[-10:]
|
||||
|
||||
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
|
||||
for msg in recent_messages[:-1]:
|
||||
role = "user" if msg.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
|
||||
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
|
||||
14
blueprints/email/helpers.py
Normal file
14
blueprints/email/helpers.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
|
||||
def generate_email_token(user_id: str, secret: str) -> str:
|
||||
"""Generate a 16-char hex HMAC token for a user's email address."""
|
||||
return hmac.new(
|
||||
secret.encode(), str(user_id).encode(), hashlib.sha256
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def get_user_email_address(token: str, domain: str) -> str:
|
||||
"""Return the routable email address for a given token."""
|
||||
return f"ask+{token}@{domain}"
|
||||
@@ -9,6 +9,8 @@ from .models import User
|
||||
from .oidc_service import OIDCUserService
|
||||
from .decorators import admin_required
|
||||
from config.oidc_config import oidc_config
|
||||
from blueprints.email.helpers import generate_email_token, get_user_email_address
|
||||
import os
|
||||
import secrets
|
||||
import httpx
|
||||
from urllib.parse import urlencode
|
||||
@@ -223,6 +225,7 @@ async def me():
|
||||
@admin_required
|
||||
async def list_users():
|
||||
users = await User.all().order_by("username")
|
||||
mailgun_domain = os.getenv("MAILGUN_DOMAIN", "")
|
||||
return jsonify([
|
||||
{
|
||||
"id": str(u.id),
|
||||
@@ -230,6 +233,8 @@ async def list_users():
|
||||
"email": u.email,
|
||||
"whatsapp_number": u.whatsapp_number,
|
||||
"auth_provider": u.auth_provider,
|
||||
"email_enabled": u.email_enabled,
|
||||
"email_address": get_user_email_address(u.email_hmac_token, mailgun_domain) if u.email_hmac_token and u.email_enabled else None,
|
||||
}
|
||||
for u in users
|
||||
])
|
||||
@@ -272,3 +277,47 @@ async def unlink_whatsapp(user_id):
|
||||
user.whatsapp_number = None
|
||||
await user.save()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/email", methods=["PUT"])
|
||||
@admin_required
|
||||
async def toggle_email(user_id):
|
||||
"""Enable email channel for a user, generating an HMAC token."""
|
||||
user = await User.get_or_none(id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
email_secret = os.getenv("EMAIL_HMAC_SECRET")
|
||||
if not email_secret:
|
||||
return jsonify({"error": "EMAIL_HMAC_SECRET not configured"}), 500
|
||||
|
||||
mailgun_domain = os.getenv("MAILGUN_DOMAIN", "")
|
||||
|
||||
if not user.email_hmac_token:
|
||||
user.email_hmac_token = generate_email_token(user.id, email_secret)
|
||||
user.email_enabled = True
|
||||
await user.save()
|
||||
|
||||
return jsonify({
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"whatsapp_number": user.whatsapp_number,
|
||||
"auth_provider": user.auth_provider,
|
||||
"email_enabled": user.email_enabled,
|
||||
"email_address": get_user_email_address(user.email_hmac_token, mailgun_domain),
|
||||
})
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/email", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def disable_email(user_id):
|
||||
"""Disable email channel and clear the token."""
|
||||
user = await User.get_or_none(id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
user.email_enabled = False
|
||||
user.email_hmac_token = None
|
||||
await user.save()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@@ -12,6 +12,10 @@ class User(Model):
|
||||
email = fields.CharField(max_length=100, unique=True)
|
||||
whatsapp_number = fields.CharField(max_length=30, unique=True, null=True, index=True)
|
||||
|
||||
# Email channel fields
|
||||
email_enabled = fields.BooleanField(default=False)
|
||||
email_hmac_token = fields.CharField(max_length=16, unique=True, null=True, index=True)
|
||||
|
||||
# OIDC fields
|
||||
oidc_subject = fields.CharField(
|
||||
max_length=255, unique=True, null=True, index=True
|
||||
|
||||
@@ -15,6 +15,7 @@ from blueprints.conversation.logic import (
|
||||
get_conversation_transcript,
|
||||
)
|
||||
from blueprints.conversation.agents import main_agent
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
|
||||
whatsapp_blueprint = Blueprint("whatsapp_api", __name__, url_prefix="/api/whatsapp")
|
||||
|
||||
@@ -170,55 +171,12 @@ async def webhook():
|
||||
|
||||
# Build messages payload for LangChain agent with system prompt and conversation history
|
||||
try:
|
||||
# System prompt with Simba's facts and medical information
|
||||
system_prompt = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
|
||||
|
||||
SIMBA FACTS (as of January 2026):
|
||||
- Name: Simba
|
||||
- Species: Feline (Domestic Short Hair / American Short Hair)
|
||||
- Sex: Male, Neutered
|
||||
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
|
||||
- Color: Orange
|
||||
- Current Weight: 16 lbs (as of 1/8/2026)
|
||||
- Owner: Ryan Chen
|
||||
- Location: Long Island City, NY
|
||||
- Veterinarian: Court Square Animal Hospital
|
||||
|
||||
Medical Conditions:
|
||||
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
|
||||
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
|
||||
|
||||
Recent Medical Events:
|
||||
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
|
||||
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
|
||||
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
|
||||
|
||||
Diet & Lifestyle:
|
||||
- Diet: Hill's I/D wet and dry food
|
||||
- Supplements: Plaque Off
|
||||
- Indoor only cat, only pet in the household
|
||||
|
||||
Upcoming Appointments:
|
||||
- Rabies Vaccine: Due 2/19/2026
|
||||
- Routine Examination: Due 6/1/2026
|
||||
- FVRCP-3yr Vaccine: Due 10/2/2026
|
||||
|
||||
IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.
|
||||
|
||||
BUDGET & FINANCE (YNAB Integration):
|
||||
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
|
||||
- Use ynab_budget_summary for overall budget health and status questions
|
||||
- Use ynab_search_transactions to find specific purchases or spending at particular stores
|
||||
- Use ynab_category_spending to analyze spending by category for a month
|
||||
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
||||
Always use these tools when asked about budgets, spending, transactions, or financial health."""
|
||||
|
||||
# Get last 10 messages for conversation history
|
||||
messages = await conversation.messages.all()
|
||||
recent_messages = list(messages)[-10:]
|
||||
|
||||
# Build messages payload
|
||||
messages_payload = [{"role": "system", "content": system_prompt}]
|
||||
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
|
||||
|
||||
# Add recent conversation history (exclude the message we just added)
|
||||
for msg in recent_messages[:-1]:
|
||||
|
||||
17
migrations/models/3_20260313000000_add_email_fields.py
Normal file
17
migrations/models/3_20260313000000_add_email_fields.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "users" ADD "email_enabled" BOOL NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE "users" ADD "email_hmac_token" VARCHAR(16) UNIQUE;
|
||||
CREATE INDEX "idx_users_email_h_a1b2c3" ON "users" ("email_hmac_token");"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "idx_users_email_h_a1b2c3";
|
||||
ALTER TABLE "users" DROP COLUMN "email_hmac_token";
|
||||
ALTER TABLE "users" DROP COLUMN "email_enabled";"""
|
||||
@@ -167,6 +167,23 @@ class UserService {
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to unlink WhatsApp number");
|
||||
}
|
||||
|
||||
async adminToggleEmail(userId: string): Promise<AdminUserRecord> {
|
||||
const response = await this.fetchWithRefreshToken(
|
||||
`${this.baseUrl}/admin/users/${userId}/email`,
|
||||
{ method: "PUT" },
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to enable email");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async adminDisableEmail(userId: string): Promise<void> {
|
||||
const response = await this.fetchWithRefreshToken(
|
||||
`${this.baseUrl}/admin/users/${userId}/email`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to disable email");
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminUserRecord {
|
||||
@@ -175,6 +192,8 @@ export interface AdminUserRecord {
|
||||
email: string;
|
||||
whatsapp_number: string | null;
|
||||
auth_provider: string;
|
||||
email_enabled: boolean;
|
||||
email_address: string | null;
|
||||
}
|
||||
|
||||
export { UserService };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Phone, PhoneOff, Pencil, Check } from "lucide-react";
|
||||
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
|
||||
import { userService, type AdminUserRecord } from "../api/userService";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -78,6 +78,44 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEmail = async (userId: string) => {
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
const updated = await userService.adminToggleEmail(userId);
|
||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled ✓" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((p) => ({
|
||||
...p,
|
||||
[userId]: err instanceof Error ? err.message : "Failed to enable email",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const disableEmail = async (userId: string) => {
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
await userService.adminDisableEmail(userId);
|
||||
setUsers((p) =>
|
||||
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)),
|
||||
);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((p) => ({
|
||||
...p,
|
||||
[userId]: err instanceof Error ? err.message : "Failed to disable email",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, userId: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Copied ✓" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-charcoal/40 backdrop-blur-sm"
|
||||
@@ -97,7 +135,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
<Phone size={14} className="text-leaf-dark" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-charcoal">
|
||||
Admin · WhatsApp Numbers
|
||||
Admin · User Integrations
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -126,6 +164,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>WhatsApp</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="w-28">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -180,6 +219,26 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{user.email_enabled && user.email_address ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-charcoal truncate max-w-[180px]" title={user.email_address}>
|
||||
{user.email_address}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(user.email_address!, user.id)}
|
||||
className="text-warm-gray hover:text-charcoal transition-colors cursor-pointer"
|
||||
title="Copy address"
|
||||
>
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-warm-gray/40 italic">—</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editingId === user.id ? (
|
||||
<div className="flex gap-1.5">
|
||||
@@ -219,6 +278,25 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
Unlink
|
||||
</Button>
|
||||
)}
|
||||
{user.email_enabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => disableEmail(user.id)}
|
||||
>
|
||||
<Mail size={11} />
|
||||
Email
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => toggleEmail(user.id)}
|
||||
>
|
||||
<Mail size={11} />
|
||||
Email
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user