mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
877 lines
33 KiB
TypeScript
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, "<")
|
|
.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 "<!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(/ /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<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);
|
|
}
|
|
});
|