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