import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; type SenderConfig = { host: string; port?: number; username: string; password: string; use_tls?: boolean; use_ssl?: boolean; from: string; fromEmail?: string; fromName?: string; envelopeFrom?: string; signature_html?: string; }; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", }; const FALLBACK_PUBLIC_SUPABASE_URL = "https://yqdefzjapnzabowsgoyd.supabase.co"; function getPublicFunctionBaseUrl() { const explicitUrl = Deno.env.get("PUBLIC_SUPABASE_URL") || Deno.env.get("PUBLIC_FUNCTION_BASE_URL") || Deno.env.get("VITE_SUPABASE_URL"); if (explicitUrl) return explicitUrl.replace(/\/$/, ""); const internalUrl = (Deno.env.get("SUPABASE_URL") || "").replace(/\/$/, ""); if (/^https:\/\/[^/]+\.supabase\.co$/i.test(internalUrl)) return internalUrl; const projectRef = Deno.env.get("SUPABASE_PROJECT_REF") || Deno.env.get("SB_PROJECT_REF"); if (projectRef) return `https://${projectRef}.supabase.co`; return FALLBACK_PUBLIC_SUPABASE_URL; } const ALLOWED_ROLES = ["admin", "manager", "staff", "employee", "board_member", "arc_member", "fining_member"] as const; function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } async function getAuthorizedCaller(req: Request) { const authHeader = req.headers.get("authorization") || ""; if (!authHeader.startsWith("Bearer ")) { return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) }; } const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!; const callerClient = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } }, }); const token = authHeader.replace("Bearer ", ""); const { data: claimsData, error: claimsError } = await callerClient.auth.getClaims(token); const callerId = claimsData?.claims?.sub; if (claimsError || !callerId) { console.error("[send-smtp-email] Auth failed - claimsError:", claimsError?.message, "callerId:", callerId); return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) }; } console.log("[send-smtp-email] Authenticated user:", callerId); for (const role of ALLOWED_ROLES) { const { data: hasRole } = await callerClient.rpc("has_role", { _user_id: callerId, _role: role, }); if (hasRole) { console.log("[send-smtp-email] User has role:", role); return { callerId, authHeader }; } } console.error("[send-smtp-email] No matching role found for user:", callerId); return { error: jsonResponse({ success: false, error: "Insufficient permissions" }, 403) }; } async function getAuthenticatedCaller(req: Request) { const authHeader = req.headers.get("authorization") || ""; if (!authHeader.startsWith("Bearer ")) { return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) }; } const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!; const callerClient = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } }, }); const token = authHeader.replace("Bearer ", ""); const { data: claimsData, error: claimsError } = await callerClient.auth.getClaims(token); const callerId = claimsData?.claims?.sub; if (claimsError || !callerId) { return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) }; } return { callerId }; } function escapeHtml(value: string) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function mapSenderRowToConfig(sender: any): SenderConfig { const port = Number(sender.smtp_port ?? 587); const isImplicitSslPort = port === 465; const isStartTlsPort = port === 587; const envelopeFrom = sender.smtp_username || sender.email_address; return { host: sender.smtp_host, port, username: sender.smtp_username, password: sender.smtp_password, use_ssl: isImplicitSslPort ? true : sender.use_ssl ?? false, use_tls: isImplicitSslPort ? false : sender.use_tls ?? isStartTlsPort, from: sender.sender_name ? `${sender.sender_name} <${sender.email_address}>` : sender.email_address, fromEmail: sender.email_address, fromName: sender.sender_name, envelopeFrom, signature_html: sender.signature_html || "", }; } function ensureHtmlDocument(htmlContent: string) { const trimmed = String(htmlContent ?? "").trim(); if (!trimmed) return ""; if (/]/i.test(trimmed)) return trimmed; return `${trimmed}`; } function toPlainText(htmlContent: string) { return htmlContent .replace(/<\s*br\s*\/?>/gi, "\n") .replace(/<\/(p|div|h1|h2|h3|h4|h5|h6|li|tr|section)\s*>/gi, "\n") .replace(//gi, "") .replace(//gi, "") .replace(/<[^>]+>/g, "") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/\r\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function toBase64Utf8(value: string) { const bytes = new TextEncoder().encode(value); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } function chunkBase64(value: string, lineLength = 76) { const lines: string[] = []; for (let i = 0; i < value.length; i += lineLength) { lines.push(value.slice(i, i + lineLength)); } return lines.join("\r\n"); } function sanitizeHeaderValue(value: string) { return String(value ?? "") .replace(/[\r\n]+/g, " ") .trim(); } function extractEmailAddress(value: string) { const sanitized = sanitizeHeaderValue(value); const bracketMatch = sanitized.match(/<([^>]+)>/); if (bracketMatch?.[1]) return bracketMatch[1].trim(); return sanitized; } function extractDisplayName(value: string) { const sanitized = sanitizeHeaderValue(value); const bracketIndex = sanitized.indexOf("<"); if (bracketIndex <= 0) return ""; return sanitized.slice(0, bracketIndex).trim().replace(/^"|"$/g, ""); } function formatAddressHeader(email: string, displayName?: string) { const safeEmail = sanitizeHeaderValue(email); const safeName = sanitizeHeaderValue(displayName || ""); if (!safeName) return safeEmail; const escapedName = safeName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const needsQuotes = /[",;<>@\[\]\(\):]/.test(escapedName); return needsQuotes ? `"${escapedName}" <${safeEmail}>` : `${escapedName} <${safeEmail}>`; } function normalizeRecipients(recipients: string[]) { return recipients .map((recipient) => sanitizeHeaderValue(recipient)) .filter(Boolean); } function dotStuffSmtpData(content: string) { const normalized = content.replace(/\r?\n/g, "\r\n"); return normalized.replace(/(^|\r\n)\./g, "$1.."); } function buildSenderKey(sender: any) { return `${String(sender.email_address ?? "").trim().toLowerCase()}::${String(sender.smtp_username ?? "").trim().toLowerCase()}`; } function sortSendersForDisplay(a: any, b: any) { const defaultDiff = Number(Boolean(b.is_default)) - Number(Boolean(a.is_default)); if (defaultDiff !== 0) return defaultDiff; const verifiedDiff = Number(Boolean(b.verified)) - Number(Boolean(a.verified)); if (verifiedDiff !== 0) return verifiedDiff; return new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime(); } const senderSelectFields = "id, sender_name, email_address, smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl, is_active, verified, is_default, updated_at, signature_html"; async function resolveLatestSenderVariant(adminClient: any, sender: any) { const normalizedEmail = String(sender?.email_address ?? "").trim().toLowerCase(); const normalizedUsername = String(sender?.smtp_username ?? "").trim().toLowerCase(); if (!normalizedEmail || !normalizedUsername) { return sender; } const { data: matchingSenders, error } = await adminClient .from("email_senders") .select(senderSelectFields) .eq("is_active", true) .eq("email_address", normalizedEmail) .eq("smtp_username", normalizedUsername) .order("updated_at", { ascending: false }); if (error || !matchingSenders?.length) { return sender; } const [latestSender] = matchingSenders.sort((a: any, b: any) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime()); if (latestSender?.id && latestSender.id !== sender.id) { console.log("[send-smtp-email] Replaced stale sender", sender.id, "with latest sender", latestSender.id); } return latestSender ?? sender; } async function resolveSender(payload: any, adminClient: any, req: Request) { if (payload.sender_id) { const auth = await getAuthorizedCaller(req); if (auth.error) return auth; const { data: sender, error } = await adminClient .from("email_senders") .select(senderSelectFields) .eq("id", payload.sender_id) .maybeSingle(); if (error || !sender) { return { error: jsonResponse({ success: false, error: "Sender not found or inactive. Please update your sender in Email Settings." }, 404) }; } const freshestSender = await resolveLatestSenderVariant(adminClient, sender); if (!freshestSender?.is_active) { return { error: jsonResponse({ success: false, error: "Sender not found or inactive. Please update your sender in Email Settings." }, 404) }; } return { sender: mapSenderRowToConfig(freshestSender), callerId: auth.callerId }; } if (payload.sender) { return { sender: payload.sender, callerId: payload.fallback_user_id ?? null }; } return { error: jsonResponse({ success: false, error: "Missing sender configuration" }, 400) }; } const SMTP_CONNECT_TIMEOUT_MS = 20000; const SMTP_COMMAND_TIMEOUT_MS = 30000; async function withTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise { let timeoutId: number | undefined; try { return await new Promise((resolve, reject) => { timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); promise.then(resolve).catch(reject); }); } finally { if (timeoutId !== undefined) clearTimeout(timeoutId); } } async function sendViaSMTP( sender: SenderConfig, toList: string[], ccList: string[], bccList: string[], subject: string, htmlContent: string, attachments: any[], debug: boolean ): Promise<{ success: boolean; error?: string; diagnosticLogs: string[]; smtpResponse?: string }> { const { host, port = 587, username, password, use_tls = true, use_ssl = false, from, fromEmail, fromName, envelopeFrom, } = sender; const diagnosticLogs: string[] = []; if (!host || !username || !password || !from) { return { success: false, error: "Incomplete sender configuration (missing host, username, password, or from address)", diagnosticLogs }; } const boundary = `----=_Part_${crypto.randomUUID().replace(/-/g, "")}`; const alternativeBoundary = `----=_Alt_${crypto.randomUUID().replace(/-/g, "")}`; let mimeMessage = ""; const hasAttachments = Array.isArray(attachments) && attachments.length > 0; const resolvedFromEmail = sanitizeHeaderValue( fromEmail || envelopeFrom || username || extractEmailAddress(from) ); const resolvedFromName = sanitizeHeaderValue(fromName || extractDisplayName(from)); const fromHeader = formatAddressHeader(resolvedFromEmail, resolvedFromName); const safeHtmlContent = ensureHtmlDocument(htmlContent); const plainTextContent = toPlainText(safeHtmlContent) || subject; const messageIdDomain = resolvedFromEmail.includes("@") ? resolvedFromEmail.split("@")[1] : host; const messageId = `<${crypto.randomUUID()}@${messageIdDomain}>`; const safeSubject = sanitizeHeaderValue(subject); const normalizedToList = normalizeRecipients(toList); const normalizedCcList = normalizeRecipients(ccList); const normalizedBccList = normalizeRecipients(bccList); const headers: string[] = [ `From: ${fromHeader}`, `To: ${normalizedToList.join(", ")}`, `Reply-To: ${resolvedFromEmail}`, ]; if (normalizedCcList.length > 0) headers.push(`Cc: ${normalizedCcList.join(", ")}`); headers.push(`Subject: ${safeSubject}`); headers.push(`Date: ${new Date().toUTCString()}`); headers.push(`Message-ID: ${messageId}`); headers.push(`MIME-Version: 1.0`); if (hasAttachments) { headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); mimeMessage = headers.join("\r\n") + "\r\n\r\n"; mimeMessage += `--${boundary}\r\n`; mimeMessage += `Content-Type: multipart/alternative; boundary="${alternativeBoundary}"\r\n\r\n`; mimeMessage += `--${alternativeBoundary}\r\n`; mimeMessage += `Content-Type: text/plain; charset=UTF-8\r\n`; mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`; mimeMessage += chunkBase64(toBase64Utf8(plainTextContent)) + "\r\n\r\n"; mimeMessage += `--${alternativeBoundary}\r\n`; mimeMessage += `Content-Type: text/html; charset=UTF-8\r\n`; mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`; mimeMessage += chunkBase64(toBase64Utf8(safeHtmlContent)) + "\r\n\r\n"; mimeMessage += `--${alternativeBoundary}--\r\n\r\n`; for (const att of attachments) { const filename = att.filename || att.name || "attachment"; let attachmentContent = att.content || ""; if (att.path && !att.content) { try { const resp = await fetch(att.path); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const arrBuf = await resp.arrayBuffer(); const bytes = new Uint8Array(arrBuf); // Memory-efficient base64 encoding: process in chunks to avoid // building a huge intermediate binary string (which doubles memory). const CHUNK = 0x8000; // 32KB const parts: string[] = []; for (let i = 0; i < bytes.length; i += CHUNK) { const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length)); // String.fromCharCode.apply is fast and avoids per-char concat. parts.push(String.fromCharCode.apply(null, Array.from(slice))); } attachmentContent = btoa(parts.join("")); if (debug) { diagnosticLogs.push(`Attachment ${filename}: ${bytes.length} bytes -> ${attachmentContent.length} base64 chars`); } } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(`Failed to fetch attachment ${filename}:`, e); diagnosticLogs.push(`WARN: Failed to fetch attachment ${filename}: ${message}`); continue; } } mimeMessage += `--${boundary}\r\n`; mimeMessage += `Content-Type: application/octet-stream; name="${filename}"\r\n`; mimeMessage += `Content-Disposition: attachment; filename="${filename}"\r\n`; mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`; // Build chunked base64 in an array, then join once (avoids O(n²) string concat) const lines: string[] = []; for (let i = 0; i < attachmentContent.length; i += 76) { lines.push(attachmentContent.substring(i, i + 76)); } mimeMessage += lines.join("\r\n") + "\r\n\r\n"; } mimeMessage += `--${boundary}--\r\n`; } else { headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); mimeMessage = headers.join("\r\n") + "\r\n\r\n"; mimeMessage += `--${boundary}\r\n`; mimeMessage += `Content-Type: text/plain; charset=UTF-8\r\n`; mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`; mimeMessage += chunkBase64(toBase64Utf8(plainTextContent)) + "\r\n\r\n"; mimeMessage += `--${boundary}\r\n`; mimeMessage += `Content-Type: text/html; charset=UTF-8\r\n`; mimeMessage += `Content-Transfer-Encoding: base64\r\n\r\n`; mimeMessage += chunkBase64(toBase64Utf8(safeHtmlContent)) + "\r\n\r\n"; mimeMessage += `--${boundary}--\r\n`; } let conn: Deno.Conn | Deno.TlsConn; try { if (debug) { diagnosticLogs.push(`Connecting to ${host}:${port} using ${use_ssl ? "implicit SSL/TLS" : "plain SMTP"}`); } conn = use_ssl ? await withTimeout( Deno.connectTls({ hostname: host, port: Number(port) }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting securely to SMTP server ${host}:${port}` ) : await withTimeout( Deno.connect({ hostname: host, port: Number(port) }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting to SMTP server ${host}:${port}` ); } catch (connErr) { const message = connErr instanceof Error ? connErr.message : String(connErr); return { success: false, error: `Failed to connect to SMTP server ${host}:${port} — ${message}`, diagnosticLogs }; } const encoder = new TextEncoder(); const decoder = new TextDecoder(); let authSecretCommandsRemaining = 0; const readResponse = async (c: Deno.Conn | Deno.TlsConn): Promise => { const buf = new Uint8Array(4096); const n = await withTimeout( c.read(buf), SMTP_COMMAND_TIMEOUT_MS, `Timed out waiting for SMTP response from ${host}:${port}` ); if (n === null) { throw new Error("SMTP server closed the connection unexpectedly."); } const response = decoder.decode(buf.subarray(0, n)); if (debug) diagnosticLogs.push(`S: ${response.trim()}`); return response; }; const sendCommand = async (c: Deno.Conn | Deno.TlsConn, command: string): Promise => { if (debug) { let logCmd = command.trim(); if (command.startsWith("AUTH LOGIN")) { logCmd = "AUTH LOGIN ***"; authSecretCommandsRemaining = 2; } else if (authSecretCommandsRemaining > 0) { logCmd = "***"; authSecretCommandsRemaining -= 1; } diagnosticLogs.push(`C: ${logCmd}`); } await withTimeout( c.write(encoder.encode(command + "\r\n")), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending SMTP command to ${host}:${port}` ); return await readResponse(c); }; try { await readResponse(conn); const ehloResp = await sendCommand(conn, "EHLO localhost"); let activeConn: Deno.Conn | Deno.TlsConn = conn; if (!use_ssl && use_tls && ehloResp.includes("STARTTLS")) { const starttlsResp = await sendCommand(conn, "STARTTLS"); if (starttlsResp.startsWith("220")) { activeConn = await withTimeout( Deno.startTls(conn as Deno.TcpConn, { hostname: host }), SMTP_COMMAND_TIMEOUT_MS, `Timed out upgrading SMTP connection with STARTTLS for ${host}:${port}` ); await sendCommand(activeConn, "EHLO localhost"); } else { // STARTTLS temporarily unavailable — retry once after a short delay if (debug) diagnosticLogs.push(`STARTTLS failed (${starttlsResp.trim()}), retrying in 2s...`); await new Promise(r => setTimeout(r, 2000)); const retryResp = await sendCommand(conn, "STARTTLS"); if (retryResp.startsWith("220")) { activeConn = await withTimeout( Deno.startTls(conn as Deno.TcpConn, { hostname: host }), SMTP_COMMAND_TIMEOUT_MS, `Timed out upgrading SMTP connection with STARTTLS for ${host}:${port}` ); await sendCommand(activeConn, "EHLO localhost"); } else { conn.close(); return { success: false, error: `SMTP STARTTLS not available: ${retryResp.trim()}. Try again shortly or switch to port 465 (SSL).`, diagnosticLogs }; } } } await sendCommand(activeConn, "AUTH LOGIN"); await sendCommand(activeConn, btoa(username)); const authResp = await sendCommand(activeConn, btoa(password)); if (!authResp.startsWith("235")) { activeConn.close(); return { success: false, error: `SMTP authentication failed. Check your sender credentials for ${from}.`, diagnosticLogs }; } const mailFromResp = await sendCommand(activeConn, `MAIL FROM:<${resolvedFromEmail}>`); if (!mailFromResp.startsWith("250")) { activeConn.close(); return { success: false, error: `SMTP MAIL FROM rejected: ${mailFromResp.trim()}`, diagnosticLogs }; } const allRecipients = [...normalizedToList, ...normalizedCcList, ...normalizedBccList]; let acceptedRecipients = 0; for (const r of allRecipients) { const rcptResp = await sendCommand(activeConn, `RCPT TO:<${r.trim()}>`); if (!rcptResp.startsWith("250") && !rcptResp.startsWith("251")) { diagnosticLogs.push(`WARN: RCPT TO rejected for ${r}: ${rcptResp.trim()}`); } else { acceptedRecipients += 1; } } if (acceptedRecipients === 0) { activeConn.close(); return { success: false, error: "SMTP rejected all recipients. Check the recipient address and sender/domain alignment.", diagnosticLogs, }; } const dataResp = await sendCommand(activeConn, "DATA"); if (!dataResp.startsWith("354")) { activeConn.close(); return { success: false, error: `SMTP DATA command rejected: ${dataResp.trim()}`, diagnosticLogs }; } // Write message body in chunks to prevent SMTP server idle timeout const fullData = dotStuffSmtpData(mimeMessage) + "\r\n.\r\n"; const CHUNK_SIZE = 4096; for (let offset = 0; offset < fullData.length; offset += CHUNK_SIZE) { const chunk = fullData.slice(offset, offset + CHUNK_SIZE); await withTimeout( activeConn.write(encoder.encode(chunk)), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending SMTP message body chunk to ${host}:${port}` ); } const finalResp = await readResponse(activeConn); const smtpResponse = finalResp.trim(); await sendCommand(activeConn, "QUIT"); activeConn.close(); if (!finalResp.startsWith("250")) { return { success: false, error: `SMTP server rejected message: ${finalResp.trim()}`, diagnosticLogs }; } return { success: true, diagnosticLogs, smtpResponse }; } catch (smtpErr) { try { conn.close(); } catch (_) {} const message = smtpErr instanceof Error ? smtpErr.message : String(smtpErr); return { success: false, error: `SMTP error: ${message}`, diagnosticLogs }; } } Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } try { const payload = await req.json(); console.log("[send-smtp-email] Request received, action:", payload.action || "send", "sender_id:", payload.sender_id || "none"); const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const adminClient = createClient(supabaseUrl, serviceKey, { auth: { autoRefreshToken: false, persistSession: false }, }); // === List Senders === if (payload.action === "list_senders") { const auth = await getAuthorizedCaller(req); if (auth.error) return auth.error; const { data: senders, error } = await adminClient .from("email_senders") .select("id, sender_name, email_address, smtp_username, is_default, updated_at, verified") .eq("is_active", true) .order("is_default", { ascending: false }) .order("updated_at", { ascending: false }); if (error) { return jsonResponse({ success: false, error: (error as Error).message }, 500); } const latestSenderByKey = new Map(); for (const sender of (senders ?? []).sort((a, b) => new Date(b.updated_at ?? 0).getTime() - new Date(a.updated_at ?? 0).getTime())) { const key = buildSenderKey(sender); if (!latestSenderByKey.has(key)) { latestSenderByKey.set(key, sender); } } const dedupedSenders = Array.from(latestSenderByKey.values()).sort(sortSendersForDisplay); return jsonResponse({ success: true, senders: dedupedSenders }); } if (payload.action === "send_direct_message_notification") { const auth = await getAuthenticatedCaller(req); if (auth.error) return auth.error; const recipientIds = Array.isArray(payload.recipient_ids) ? payload.recipient_ids.filter((id: unknown) => typeof id === "string" && id.trim()) : []; const message = String(payload.message ?? "").trim(); const senderName = String(payload.sender_name ?? "Someone").trim() || "Someone"; if (recipientIds.length === 0 || !message) { return jsonResponse({ success: false, error: "Missing required fields: recipient_ids, message" }, 400); } const { data: profiles, error: profileError } = await adminClient .from("profiles") .select("user_id, full_name, email, preferred_notification_email") .in("user_id", recipientIds); if (profileError) return jsonResponse({ success: false, error: profileError.message }, 500); const recipients = (profiles ?? []) .map((profile: any) => ({ email: String(profile.preferred_notification_email || profile.email || "").trim(), name: String(profile.full_name || "there").trim(), })) .filter((recipient) => /.+@.+\..+/.test(recipient.email)); if (recipients.length === 0) { return jsonResponse({ success: true, sent: 0, skipped: recipientIds.length, reason: "No recipient email addresses found" }); } const { data: defaultSender, error: senderError } = await adminClient .from("email_senders") .select(senderSelectFields) .eq("is_active", true) .eq("is_default", true) .order("updated_at", { ascending: false }) .limit(1) .maybeSingle(); if (senderError || !defaultSender) { return jsonResponse({ success: false, error: "No default SMTP sender configured" }, 404); } const sender = mapSenderRowToConfig(await resolveLatestSenderVariant(adminClient, defaultSender)); const preview = message.replace(/\s+/g, " ").slice(0, 600); const subject = `New message from ${senderName}`; let sent = 0; const failed: { email: string; error: string }[] = []; for (const recipient of recipients) { const htmlContent = ensureHtmlDocument(`

New message from ${escapeHtml(senderName)}

Hi ${escapeHtml(recipient.name)},

You received a new message in Avria Community Management.

${escapeHtml(preview)}

Open messages

Please sign in to reply.

`); const result = await sendViaSMTP(sender, [recipient.email], [], [], subject, htmlContent, [], false); const latestServerResponse = result.smtpResponse ? `S: ${result.smtpResponse}` : [...result.diagnosticLogs].reverse().find((entry) => entry.startsWith("S: ")) ?? null; try { await adminClient.from("email_history").insert({ user_id: auth.callerId, sender_email: sender.from, recipient_email: recipient.email, subject, body_text: htmlContent, sent_at: new Date().toISOString(), status: result.success ? "accepted" : "failed", feature_type: "direct_message_notification", email_headers: result.success ? { smtp_response: latestServerResponse } : { error: result.error }, }); } catch (logErr) { console.error("Failed to log direct message notification email:", logErr); } if (result.success) sent += 1; else failed.push({ email: recipient.email, error: result.error || "SMTP send failed" }); } return jsonResponse({ success: failed.length === 0, sent, failed }); } // === Send Email === const { recipient, cc, bcc, subject, body, html, attachments, debug } = payload; if (!recipient || !subject) { return jsonResponse({ success: false, error: "Missing required fields: recipient, subject" }, 400); } const resolvedSender: any = await resolveSender(payload, adminClient, req); if (resolvedSender.error) return resolvedSender.error; const sender = resolvedSender.sender as SenderConfig; console.log("[send-smtp-email] Resolved sender:", JSON.stringify({ host: sender.host, port: sender.port, username: sender.username, from: sender.from, fromEmail: sender.fromEmail, envelopeFrom: sender.envelopeFrom, use_ssl: sender.use_ssl, use_tls: sender.use_tls, hasPassword: !!sender.password, passwordLength: sender.password?.length ?? 0, })); const toList = Array.isArray(recipient) ? recipient : [recipient]; const ccList = Array.isArray(cc) ? cc.filter(Boolean) : []; const bccList = Array.isArray(bcc) ? bcc.filter(Boolean) : []; let htmlContent = html || body || ""; // Append sender signature if configured if (sender.signature_html) { const sigBlock = `
${sender.signature_html}
`; if (htmlContent.includes("")) { htmlContent = htmlContent.replace("", `${sigBlock}`); } else { htmlContent += sigBlock; } } // Inject a 1x1 tracking pixel so opens get recorded into email_history. const trackingId = crypto.randomUUID(); const pixelUrl = `${getPublicFunctionBaseUrl()}/functions/v1/track-email-open?tid=${trackingId}`; const pixelTag = ``; if (htmlContent.includes("")) { htmlContent = htmlContent.replace("", `${pixelTag}`); } else { htmlContent += pixelTag; } const formattedAttachments = Array.isArray(attachments) ? attachments : []; const result = await sendViaSMTP( sender, toList, ccList, bccList, subject, htmlContent, formattedAttachments, debug ?? false ); console.log("[send-smtp-email] SMTP result:", JSON.stringify({ success: result.success, error: result.error, smtpResponse: result.smtpResponse })); const latestServerResponse = result.smtpResponse ? `S: ${result.smtpResponse}` : [...result.diagnosticLogs].reverse().find((entry) => entry.startsWith("S: ")) ?? null; const mailStatus = result.success ? "accepted" : "failed"; // Log to email_history with accurate status let historyRecorded = false; let historyError: string | undefined; try { const userId = resolvedSender.callerId ?? payload.fallback_user_id; if (userId) { const { error: insertHistoryError } = await adminClient.from("email_history").insert({ user_id: userId, sender_email: sender.from, recipient_email: toList.join(", "), subject, body_text: htmlContent, sent_at: new Date().toISOString(), status: mailStatus, feature_type: payload.feature_type || "smtp_email", tracking_id: trackingId, email_headers: result.success ? { smtp_response: latestServerResponse } : { error: result.error, diagnostic_logs: debug ? result.diagnosticLogs : undefined }, }); if (insertHistoryError) { historyError = insertHistoryError.message; console.error("Failed to log email history:", insertHistoryError); } else { historyRecorded = true; } } } catch (logErr) { historyError = logErr instanceof Error ? logErr.message : String(logErr); console.error("Failed to log email history:", logErr); } if (!result.success) { return jsonResponse({ success: false, error: result.error, status: mailStatus, diagnostic_logs: debug ? result.diagnosticLogs : undefined, }, 500); } return jsonResponse({ success: true, status: mailStatus, history_recorded: historyRecorded, history_error: historyError, smtp_response: latestServerResponse, diagnostic_logs: debug ? result.diagnosticLogs : undefined, }); } catch (error) { console.error("send-smtp-email error:", error); return jsonResponse({ success: false, error: (error as Error).message }, 500); } });