Files
acmcc/src/pages/EmailHistoryPage.tsx
T
2026-06-01 20:19:26 -04:00

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