Files
acmcc/supabase/functions/send-smtp-email/index.ts
2026-06-01 20:19:26 -04:00

877 lines
33 KiB
TypeScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 "<!DOCTYPE html><html><body></body></html>";
if (/<!doctype html/i.test(trimmed) || /<html[\s>]/i.test(trimmed)) return trimmed;
return `<!DOCTYPE html><html><body>${trimmed}</body></html>`;
}
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(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/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<T>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timeoutId: number | undefined;
try {
return await new Promise<T>((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<string> => {
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<string> => {
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<string, any>();
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(`
<div style="font-family:Arial,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#172033;line-height:1.5;">
<h2 style="margin:0 0 16px;color:#1e3a8a;font-size:20px;">New message from ${escapeHtml(senderName)}</h2>
<p style="margin:0 0 14px;">Hi ${escapeHtml(recipient.name)},</p>
<p style="margin:0 0 16px;">You received a new message in Avria Community Management.</p>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:18px 0;white-space:pre-wrap;">${escapeHtml(preview)}</div>
<p style="margin:22px 0;"><a href="https://avria.cloud/dashboard/messages" style="background:#1e3a8a;color:#ffffff;padding:12px 18px;text-decoration:none;border-radius:6px;display:inline-block;font-weight:600;">Open messages</a></p>
<p style="font-size:12px;color:#64748b;margin-top:24px;">Please sign in to reply.</p>
</div>
`);
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 = `<br/><div style="margin-top:16px;border-top:1px solid #e0e0e0;padding-top:12px">${sender.signature_html}</div>`;
if (htmlContent.includes("</body>")) {
htmlContent = htmlContent.replace("</body>", `${sigBlock}</body>`);
} 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 = `<img src="${pixelUrl}" width="1" height="1" alt="" style="display:none!important;border:0;outline:none;text-decoration:none;width:1px;height:1px;opacity:0;overflow:hidden;" />`;
if (htmlContent.includes("</body>")) {
htmlContent = htmlContent.replace("</body>", `${pixelTag}</body>`);
} 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);
}
});