mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
15 KiB
TypeScript
309 lines
15 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Mail, RefreshCw, Search, Download, Eye, FileBadge2, Loader2, MailOpen } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import { formatDateTimeShortEST } from "@/lib/timezoneUtils";
|
|
import { generateEmailHistoryProofPdf } from "@/lib/emailHistoryProofPdfGenerator";
|
|
|
|
const statusColors: Record<string, string> = {
|
|
accepted: "bg-emerald-100 text-emerald-700",
|
|
sent: "bg-emerald-100 text-emerald-700",
|
|
delivered: "bg-emerald-100 text-emerald-700",
|
|
failed: "bg-destructive/10 text-destructive",
|
|
pending: "bg-amber-100 text-amber-700",
|
|
bounced: "bg-destructive/10 text-destructive",
|
|
};
|
|
|
|
const authTemplateSubjects: Record<string, string> = {
|
|
signup: "Confirm your email",
|
|
invite: "You've been invited",
|
|
magiclink: "Your login link",
|
|
recovery: "Reset your password",
|
|
email_change: "Confirm your new email",
|
|
reauthentication: "Your verification code",
|
|
};
|
|
|
|
export default function EmailHistoryPage() {
|
|
const { user } = useAuth();
|
|
const { toast } = useToast();
|
|
const [emails, setEmails] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [recipientFilter, setRecipientFilter] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
const [selectedEmail, setSelectedEmail] = useState<any | null>(null);
|
|
const [generatingProofId, setGeneratingProofId] = useState<string | null>(null);
|
|
|
|
useEffect(() => { if (user) fetchHistory(); }, [user]);
|
|
|
|
const fetchHistory = async () => {
|
|
setLoading(true);
|
|
try {
|
|
let query = supabase.from("email_history").select("*").order("sent_at", { ascending: false }).limit(100);
|
|
if (recipientFilter.trim()) query = query.ilike("recipient_email", `%${recipientFilter.trim()}%`);
|
|
if (statusFilter !== "all") query = query.eq("status", statusFilter);
|
|
const { data, error } = await query;
|
|
if (error) throw error;
|
|
|
|
let authQuery = (supabase as any)
|
|
.from("email_send_log")
|
|
.select("id, message_id, template_name, recipient_email, status, error_message, created_at")
|
|
.order("created_at", { ascending: false })
|
|
.limit(100);
|
|
if (recipientFilter.trim()) authQuery = authQuery.ilike("recipient_email", `%${recipientFilter.trim()}%`);
|
|
if (statusFilter !== "all") authQuery = authQuery.eq("status", statusFilter);
|
|
const { data: authRows, error: authError } = await authQuery;
|
|
if (authError) throw authError;
|
|
|
|
const latestAuthByMessage = new Map<string, any>();
|
|
(authRows || []).forEach((row: any) => {
|
|
if (!row.message_id || latestAuthByMessage.has(row.message_id)) return;
|
|
latestAuthByMessage.set(row.message_id, {
|
|
id: `auth-${row.id}`,
|
|
sender_email: "Avria Community Cloud <noreply@notify.avriamail.com>",
|
|
recipient_email: row.recipient_email,
|
|
subject: authTemplateSubjects[row.template_name] || row.template_name || "Auth email",
|
|
status: row.status,
|
|
sent_at: row.created_at,
|
|
body_text: row.error_message
|
|
? `<p>${row.error_message}</p>`
|
|
: `<p>This authentication email was sent by Lovable Cloud. The delivered body is generated from the active auth email template.</p>`,
|
|
email_headers: row.error_message ? { error: row.error_message } : null,
|
|
proof_url: null,
|
|
opened_at: null,
|
|
open_count: 0,
|
|
source: "auth",
|
|
});
|
|
});
|
|
|
|
const combined = [...(data || []), ...Array.from(latestAuthByMessage.values())].sort(
|
|
(a, b) => new Date(b.sent_at || 0).getTime() - new Date(a.sent_at || 0).getTime()
|
|
);
|
|
setEmails(combined.slice(0, 100));
|
|
} catch (err: any) {
|
|
toast({ variant: "destructive", title: "Error", description: err.message });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatDate = (d: string) => {
|
|
if (!d) return "—";
|
|
return formatDateTimeShortEST(d);
|
|
};
|
|
|
|
const handleGenerateProof = async (email: any) => {
|
|
setGeneratingProofId(email.id);
|
|
try {
|
|
const result = await generateEmailHistoryProofPdf(email);
|
|
if (email.source !== "auth") {
|
|
const { error } = await supabase.from("email_history").update({ proof_url: result.publicUrl }).eq("id", email.id);
|
|
if (error) throw error;
|
|
}
|
|
|
|
setEmails((prev) => prev.map((item) => item.id === email.id ? { ...item, proof_url: result.publicUrl } : item));
|
|
if (selectedEmail?.id === email.id) {
|
|
setSelectedEmail((prev: any) => ({ ...prev, proof_url: result.publicUrl }));
|
|
}
|
|
toast({ title: "Proof Ready", description: "Proof PDF generated successfully." });
|
|
} catch (err: any) {
|
|
toast({ variant: "destructive", title: "Proof Failed", description: err.message || "Could not generate proof." });
|
|
} finally {
|
|
setGeneratingProofId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Mail className="h-7 w-7 text-primary" />
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">Email History</h1>
|
|
<p className="text-sm text-muted-foreground">Review delivery results, open the full email, and generate proof documents.</p>
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" onClick={fetchHistory} disabled={loading}>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} /> Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader><CardTitle>Filters</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col sm:flex-row gap-4 items-end">
|
|
<div className="flex-1 space-y-1">
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Recipient Email</label>
|
|
<Input placeholder="Search recipient..." value={recipientFilter} onChange={(e) => setRecipientFilter(e.target.value)} />
|
|
</div>
|
|
<div className="w-48 space-y-1">
|
|
<label className="text-xs font-semibold text-muted-foreground uppercase">Status</label>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Statuses</SelectItem>
|
|
<SelectItem value="accepted">Accepted by SMTP</SelectItem>
|
|
<SelectItem value="failed">Failed</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button onClick={fetchHistory}><Search className="h-4 w-4 mr-2" /> Apply Filters</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Date & Time</TableHead>
|
|
<TableHead>Sender</TableHead>
|
|
<TableHead>Recipient</TableHead>
|
|
<TableHead>Subject</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Opened</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{emails.length === 0 ? (
|
|
<TableRow><TableCell colSpan={7} className="text-center py-12 text-muted-foreground">No emails found.</TableCell></TableRow>
|
|
) : (
|
|
emails.map((email) => (
|
|
<TableRow key={email.id}>
|
|
<TableCell className="whitespace-nowrap">{formatDate(email.sent_at)}</TableCell>
|
|
<TableCell className="max-w-[160px] truncate">{email.sender_email || "—"}</TableCell>
|
|
<TableCell className="max-w-[180px] truncate">{email.recipient_email || "—"}</TableCell>
|
|
<TableCell className="max-w-[200px] truncate">{email.subject || "—"}</TableCell>
|
|
<TableCell>
|
|
<Badge className={statusColors[email.status] || "bg-muted text-muted-foreground"} variant="secondary">
|
|
{email.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{email.opened_at ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<MailOpen className="h-3.5 w-3.5 text-emerald-600" />
|
|
<div className="text-xs">
|
|
<div className="text-emerald-700 font-medium">{formatDate(email.opened_at)}</div>
|
|
{email.open_count > 1 && (
|
|
<div className="text-muted-foreground">{email.open_count} opens</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">Not opened</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" size="icon" onClick={() => setSelectedEmail(email)}>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
{email.proof_url ? (
|
|
<Button variant="ghost" size="sm" onClick={() => window.open(email.proof_url, "_blank")}>
|
|
<Download className="h-4 w-4 mr-1" /> Proof PDF
|
|
</Button>
|
|
) : (
|
|
<Button variant="ghost" size="sm" onClick={() => handleGenerateProof(email)} disabled={generatingProofId === email.id}>
|
|
{generatingProofId === email.id ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileBadge2 className="h-4 w-4 mr-1" />}
|
|
Generate Proof
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={!!selectedEmail} onOpenChange={(open) => !open && setSelectedEmail(null)}>
|
|
<DialogContent className="sm:max-w-[760px] max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{selectedEmail?.subject || "Email Details"}</DialogTitle>
|
|
<DialogDescription>Review the stored email content and delivery result.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{selectedEmail && (
|
|
<div className="space-y-4 py-2">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase">From</p>
|
|
<p>{selectedEmail.sender_email || "—"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase">To</p>
|
|
<p>{selectedEmail.recipient_email || "—"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase">Sent</p>
|
|
<p>{formatDate(selectedEmail.sent_at)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase">Status</p>
|
|
<Badge className={statusColors[selectedEmail.status] || "bg-muted text-muted-foreground"} variant="secondary">
|
|
{selectedEmail.status}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase">Opened</p>
|
|
{selectedEmail.opened_at ? (
|
|
<p className="flex items-center gap-1.5">
|
|
<MailOpen className="h-4 w-4 text-emerald-600" />
|
|
{formatDate(selectedEmail.opened_at)}
|
|
{selectedEmail.open_count > 1 && (
|
|
<span className="text-muted-foreground text-xs">({selectedEmail.open_count} opens)</span>
|
|
)}
|
|
</p>
|
|
) : (
|
|
<p className="text-muted-foreground">Not opened yet</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedEmail.email_headers?.error && (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
|
<p className="text-xs font-semibold text-destructive uppercase">Delivery Error</p>
|
|
<p className="text-sm text-foreground mt-1">{selectedEmail.email_headers.error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Email Body</p>
|
|
<div className="rounded-md border bg-background p-4 prose max-w-none">
|
|
<div dangerouslySetInnerHTML={{ __html: selectedEmail.body_text || "<p>No stored content.</p>" }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
{selectedEmail.proof_url ? (
|
|
<Button variant="outline" onClick={() => window.open(selectedEmail.proof_url, "_blank")}>
|
|
<Download className="h-4 w-4 mr-2" /> Open Proof PDF
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" onClick={() => handleGenerateProof(selectedEmail)} disabled={generatingProofId === selectedEmail.id}>
|
|
{generatingProofId === selectedEmail.id ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <FileBadge2 className="h-4 w-4 mr-2" />}
|
|
Generate Proof
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|