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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
Reference in New Issue
Block a user