From 6a7b1369ad2a8049a42b67e97288e4c33fe632cc Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 13 Mar 2026 16:21:18 -0400 Subject: [PATCH] Add email channel via Mailgun for Ask Simba Users can now receive a unique email address (ask+@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) --- .env.example | 11 + app.py | 2 + blueprints/conversation/__init__.py | 59 +---- blueprints/conversation/prompts.py | 57 +++++ blueprints/email/__init__.py | 217 ++++++++++++++++++ blueprints/email/helpers.py | 14 ++ blueprints/users/__init__.py | 49 ++++ blueprints/users/models.py | 4 + blueprints/whatsapp/__init__.py | 46 +--- .../3_20260313000000_add_email_fields.py | 17 ++ raggr-frontend/src/api/userService.ts | 19 ++ raggr-frontend/src/components/AdminPanel.tsx | 82 ++++++- 12 files changed, 474 insertions(+), 103 deletions(-) create mode 100644 blueprints/conversation/prompts.py create mode 100644 blueprints/email/__init__.py create mode 100644 blueprints/email/helpers.py create mode 100644 migrations/models/3_20260313000000_add_email_fields.py diff --git a/.env.example b/.env.example index 14071c9..947a07f 100644 --- a/.env.example +++ b/.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 diff --git a/app.py b/app.py index c558b74..13ba978 100644 --- a/app.py +++ b/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 diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index 517e006..28b6765 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -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: diff --git a/blueprints/conversation/prompts.py b/blueprints/conversation/prompts.py new file mode 100644 index 0000000..a54147b --- /dev/null +++ b/blueprints/conversation/prompts.py @@ -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.""" diff --git a/blueprints/email/__init__.py b/blueprints/email/__init__.py new file mode 100644 index 0000000..de6920b --- /dev/null +++ b/blueprints/email/__init__.py @@ -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 ", + "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+@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 diff --git a/blueprints/email/helpers.py b/blueprints/email/helpers.py new file mode 100644 index 0000000..d094579 --- /dev/null +++ b/blueprints/email/helpers.py @@ -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}" diff --git a/blueprints/users/__init__.py b/blueprints/users/__init__.py index 62a23a6..ec3f0f7 100644 --- a/blueprints/users/__init__.py +++ b/blueprints/users/__init__.py @@ -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//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//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}) diff --git a/blueprints/users/models.py b/blueprints/users/models.py index bb3f2d9..82ea099 100644 --- a/blueprints/users/models.py +++ b/blueprints/users/models.py @@ -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 diff --git a/blueprints/whatsapp/__init__.py b/blueprints/whatsapp/__init__.py index e71b5c3..6b94efd 100644 --- a/blueprints/whatsapp/__init__.py +++ b/blueprints/whatsapp/__init__.py @@ -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]: diff --git a/migrations/models/3_20260313000000_add_email_fields.py b/migrations/models/3_20260313000000_add_email_fields.py new file mode 100644 index 0000000..c4a6e11 --- /dev/null +++ b/migrations/models/3_20260313000000_add_email_fields.py @@ -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";""" diff --git a/raggr-frontend/src/api/userService.ts b/raggr-frontend/src/api/userService.ts index a325fbe..8f816c5 100644 --- a/raggr-frontend/src/api/userService.ts +++ b/raggr-frontend/src/api/userService.ts @@ -167,6 +167,23 @@ class UserService { ); if (!response.ok) throw new Error("Failed to unlink WhatsApp number"); } + + async adminToggleEmail(userId: string): Promise { + 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 { + 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 }; diff --git a/raggr-frontend/src/components/AdminPanel.tsx b/raggr-frontend/src/components/AdminPanel.tsx index e017ccf..6b55853 100644 --- a/raggr-frontend/src/components/AdminPanel.tsx +++ b/raggr-frontend/src/components/AdminPanel.tsx @@ -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 (
{

- Admin · WhatsApp Numbers + Admin · User Integrations

+ + ) : ( + + )} + + {editingId === user.id ? (
@@ -219,6 +278,25 @@ export const AdminPanel = ({ onClose }: Props) => { Unlink )} + {user.email_enabled ? ( + + ) : ( + + )}
)}