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

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