Added BCC and email logging

X-Lovable-Edit-ID: edt-a88cf2c8-f74d-44b4-a57f-03817c0eeaea
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 00:53:53 +00:00
co-authored by renee-png
5 changed files with 281 additions and 27 deletions
BIN
View File
Binary file not shown.
+60
View File
@@ -968,6 +968,63 @@ export type Database = {
},
]
}
email_logs: {
Row: {
bcc_addresses: string[]
body_html: string | null
body_text: string | null
case_id: string | null
cc_addresses: string[]
context: string | null
created_at: string
error_message: string | null
from_address: string | null
id: string
reply_to: string | null
sent_at: string
sent_by: string | null
status: string
subject: string
to_addresses: string[]
}
Insert: {
bcc_addresses?: string[]
body_html?: string | null
body_text?: string | null
case_id?: string | null
cc_addresses?: string[]
context?: string | null
created_at?: string
error_message?: string | null
from_address?: string | null
id?: string
reply_to?: string | null
sent_at?: string
sent_by?: string | null
status?: string
subject: string
to_addresses?: string[]
}
Update: {
bcc_addresses?: string[]
body_html?: string | null
body_text?: string | null
case_id?: string | null
cc_addresses?: string[]
context?: string | null
created_at?: string
error_message?: string | null
from_address?: string | null
id?: string
reply_to?: string | null
sent_at?: string
sent_by?: string | null
status?: string
subject?: string
to_addresses?: string[]
}
Relationships: []
}
expenses: {
Row: {
amount: number
@@ -1789,6 +1846,7 @@ export type Database = {
smtp_settings: {
Row: {
created_at: string
default_bcc: string | null
enabled: boolean
from_email: string
from_name: string | null
@@ -1804,6 +1862,7 @@ export type Database = {
}
Insert: {
created_at?: string
default_bcc?: string | null
enabled?: boolean
from_email: string
from_name?: string | null
@@ -1819,6 +1878,7 @@ export type Database = {
}
Update: {
created_at?: string
default_bcc?: string | null
enabled?: boolean
from_email?: string
from_name?: string | null
+99 -7
View File
@@ -8,7 +8,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Loader2, Mail, Send, KeyRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Loader2, Mail, Send, KeyRound, History } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/smtp")({
@@ -23,10 +24,22 @@ const EMPTY = {
from_email: "",
from_name: "",
reply_to: "",
default_bcc: "",
enabled: true,
notes: "",
};
interface EmailLog {
id: string;
sent_at: string;
to_addresses: string[];
bcc_addresses: string[];
subject: string;
status: string;
error_message: string | null;
context: string | null;
}
function SmtpSettingsPage() {
const { user, isAdmin } = useAuth();
const [loading, setLoading] = useState(true);
@@ -35,15 +48,24 @@ function SmtpSettingsPage() {
const [recordId, setRecordId] = useState<string | null>(null);
const [form, setForm] = useState({ ...EMPTY });
const [testTo, setTestTo] = useState("");
const [logs, setLogs] = useState<EmailLog[]>([]);
const load = async () => {
setLoading(true);
const { data } = await supabase
.from("smtp_settings")
.select("*")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
const [settingsRes, logsRes] = await Promise.all([
supabase
.from("smtp_settings")
.select("*")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from("email_logs")
.select("id, sent_at, to_addresses, bcc_addresses, subject, status, error_message, context")
.order("sent_at", { ascending: false })
.limit(25),
]);
const data = settingsRes.data;
if (data) {
setRecordId(data.id);
setForm({
@@ -54,10 +76,12 @@ function SmtpSettingsPage() {
from_email: data.from_email ?? "",
from_name: data.from_name ?? "",
reply_to: data.reply_to ?? "",
default_bcc: (data as any).default_bcc ?? "",
enabled: data.enabled ?? true,
notes: data.notes ?? "",
});
}
setLogs((logsRes.data as EmailLog[]) ?? []);
setLoading(false);
};
@@ -83,6 +107,7 @@ function SmtpSettingsPage() {
from_email: form.from_email.trim(),
from_name: form.from_name || null,
reply_to: form.reply_to || null,
default_bcc: form.default_bcc.trim() || null,
enabled: form.enabled,
notes: form.notes || null,
updated_by: user?.id,
@@ -111,6 +136,8 @@ function SmtpSettingsPage() {
subject: "SMTP test from your firm CRM",
html: `<p>This is a test email sent from <strong>${form.from_email}</strong> via <code>${form.host}:${form.port}</code>.</p><p>If you received this, your SMTP settings are working.</p>`,
text: `Test email from ${form.from_email} via ${form.host}:${form.port}.`,
test: true,
context: "smtp_test",
},
});
setTesting(false);
@@ -118,9 +145,11 @@ function SmtpSettingsPage() {
toast.error("Send failed", {
description: (error as any)?.message || (data as any)?.error,
});
load();
return;
}
toast.success(`Test email sent to ${testTo}`);
load();
};
if (!isAdmin) {
@@ -218,6 +247,16 @@ function SmtpSettingsPage() {
onChange={(e) => update("reply_to", e.target.value)}
/>
</Field>
<Field label="Default BCC (optional)" className="sm:col-span-2">
<Input
value={form.default_bcc}
onChange={(e) => update("default_bcc", e.target.value)}
placeholder="archive@yourfirm.com, partner@yourfirm.com"
/>
<p className="text-xs text-muted-foreground mt-1">
Every outbound email will be silently BCC'd to these addresses (comma-separated).
</p>
</Field>
<Field label="Notes" className="sm:col-span-2">
<Textarea
rows={2}
@@ -271,6 +310,59 @@ function SmtpSettingsPage() {
</Button>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" /> Recent email log
</CardTitle>
</CardHeader>
<CardContent>
{logs.length === 0 ? (
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
) : (
<div className="space-y-2">
{logs.map((log) => (
<div
key={log.id}
className="flex items-start justify-between gap-3 p-2 rounded-md border text-sm"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium truncate">{log.subject}</span>
<Badge
variant={log.status === "sent" ? "secondary" : "destructive"}
className="text-[10px] uppercase"
>
{log.status}
</Badge>
{log.context && (
<span className="text-[10px] text-muted-foreground">
{log.context}
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">
To: {log.to_addresses.join(", ") || "—"}
{log.bcc_addresses.length > 0 && (
<> · BCC: {log.bcc_addresses.join(", ")}</>
)}
</div>
{log.error_message && (
<div className="text-xs text-destructive mt-0.5 truncate">
{log.error_message}
</div>
)}
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(log.sent_at).toLocaleString()}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+78 -20
View File
@@ -18,29 +18,47 @@ interface SendBody {
bcc?: string | string[];
replyTo?: string;
test?: boolean;
context?: string;
case_id?: string;
}
function toArray(v: string | string[] | undefined | null): string[] {
if (!v) return [];
if (Array.isArray(v)) return v.map((s) => s.trim()).filter(Boolean);
return v
.split(/[,;]/)
.map((s) => s.trim())
.filter(Boolean);
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const admin = createClient(supabaseUrl, serviceKey);
let userId: string | null = null;
let body: SendBody | null = null;
let fromAddr = "";
let toList: string[] = [];
let ccList: string[] = [];
let bccList: string[] = [];
try {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return json({ error: "Missing authorization" }, 401);
}
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
// Verify caller is authenticated
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) return json({ error: "Unauthorized" }, 401);
const admin = createClient(supabaseUrl, serviceKey);
userId = userData.user.id;
// Load latest enabled SMTP settings
const { data: settings, error: setErr } = await admin
@@ -57,7 +75,7 @@ Deno.serve(async (req) => {
const password = Deno.env.get("SMTP_PASSWORD") ?? "";
if (!password) return json({ error: "SMTP_PASSWORD secret not set" }, 400);
const body = (await req.json()) as SendBody;
body = (await req.json()) as SendBody;
if (!body.to || !body.subject) {
return json({ error: "to and subject are required" }, 400);
}
@@ -69,13 +87,6 @@ Deno.serve(async (req) => {
if (settings.port === 465) useTls = true;
else if (settings.port === 587 || settings.port === 25) useTls = false;
console.log("SMTP connecting", {
host: settings.host,
port: settings.port,
tls: useTls,
hasAuth: !!settings.username,
});
const client = new SMTPClient({
connection: {
hostname: settings.host,
@@ -87,15 +98,25 @@ Deno.serve(async (req) => {
},
});
const fromAddr = settings.from_name
fromAddr = settings.from_name
? `${settings.from_name} <${settings.from_email}>`
: settings.from_email;
toList = toArray(body.to);
ccList = toArray(body.cc);
// Merge per-message BCC with default BCC from settings
bccList = [
...toArray(body.bcc),
...toArray(settings.default_bcc ?? null),
];
// dedupe
bccList = Array.from(new Set(bccList));
await client.send({
from: fromAddr,
to: body.to,
cc: body.cc,
bcc: body.bcc,
to: toList,
cc: ccList.length ? ccList : undefined,
bcc: bccList.length ? bccList : undefined,
replyTo: body.replyTo ?? settings.reply_to ?? undefined,
subject: body.subject,
content: body.text ?? "auto",
@@ -103,10 +124,47 @@ Deno.serve(async (req) => {
});
await client.close();
// Log success
await admin.from("email_logs").insert({
sent_by: userId,
to_addresses: toList,
cc_addresses: ccList,
bcc_addresses: bccList,
reply_to: body.replyTo ?? settings.reply_to ?? null,
from_address: fromAddr,
subject: body.subject,
body_html: body.html ?? null,
body_text: body.text ?? null,
status: "sent",
context: body.context ?? (body.test ? "test" : null),
case_id: body.case_id ?? null,
});
return json({ ok: true });
} catch (e) {
console.error("send-smtp-email error", e);
return json({ error: e instanceof Error ? e.message : String(e) }, 500);
const message = e instanceof Error ? e.message : String(e);
console.error("send-smtp-email error", message);
// Best-effort failure log
try {
await admin.from("email_logs").insert({
sent_by: userId,
to_addresses: toList,
cc_addresses: ccList,
bcc_addresses: bccList,
reply_to: body?.replyTo ?? null,
from_address: fromAddr || null,
subject: body?.subject ?? "(unknown)",
body_html: body?.html ?? null,
body_text: body?.text ?? null,
status: "failed",
error_message: message,
context: body?.context ?? (body?.test ? "test" : null),
case_id: body?.case_id ?? null,
});
} catch (_) {
// ignore log failure
}
return json({ error: message }, 500);
}
});
@@ -0,0 +1,44 @@
-- Add BCC default to SMTP settings
ALTER TABLE public.smtp_settings
ADD COLUMN IF NOT EXISTS default_bcc text;
-- Email log table
CREATE TABLE IF NOT EXISTS public.email_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sent_at timestamptz NOT NULL DEFAULT now(),
sent_by uuid,
to_addresses text[] NOT NULL DEFAULT '{}',
cc_addresses text[] NOT NULL DEFAULT '{}',
bcc_addresses text[] NOT NULL DEFAULT '{}',
reply_to text,
from_address text,
subject text NOT NULL,
body_html text,
body_text text,
status text NOT NULL DEFAULT 'sent',
error_message text,
context text,
case_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS email_logs_sent_at_idx ON public.email_logs (sent_at DESC);
CREATE INDEX IF NOT EXISTS email_logs_sent_by_idx ON public.email_logs (sent_by);
CREATE INDEX IF NOT EXISTS email_logs_case_id_idx ON public.email_logs (case_id);
ALTER TABLE public.email_logs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS email_logs_select ON public.email_logs;
CREATE POLICY email_logs_select ON public.email_logs
FOR SELECT TO authenticated
USING (public.is_admin(auth.uid()) OR sent_by = auth.uid());
DROP POLICY IF EXISTS email_logs_insert ON public.email_logs;
CREATE POLICY email_logs_insert ON public.email_logs
FOR INSERT TO authenticated
WITH CHECK (auth.uid() IS NOT NULL);
DROP POLICY IF EXISTS email_logs_delete ON public.email_logs;
CREATE POLICY email_logs_delete ON public.email_logs
FOR DELETE TO authenticated
USING (public.is_admin(auth.uid()));