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:
ryan
2026-03-13 16:21:18 -04:00
parent 4621755c54
commit 6a7b1369ad
12 changed files with 474 additions and 103 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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:

View 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."""

View 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

View 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}"

View File

@@ -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})

View File

@@ -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

View File

@@ -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]:

View 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";"""

View File

@@ -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 };

View File

@@ -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>