import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; 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 STAFF_ROLES = ["admin", "manager"] as const; function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } function quote(value: string) { return `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } async function getAuthorizedClient(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 client = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } }); const token = authHeader.replace("Bearer ", ""); const { data: claimsData, error } = await client.auth.getClaims(token); const callerId = claimsData?.claims?.sub; if (error || !callerId) return { error: jsonResponse({ success: false, error: "Unauthorized" }, 401) }; for (const role of STAFF_ROLES) { const { data: hasRole } = await client.rpc("has_role", { _user_id: callerId, _role: role }); if (hasRole) return { client, callerId }; } return { error: jsonResponse({ success: false, error: "Insufficient permissions" }, 403) }; } async function readUntil(reader: ReadableStreamDefaultReader, tag: string, timeoutMs = 20000) { const decoder = new TextDecoder(); let text = ""; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const { value, done } = await reader.read(); if (done) break; text += decoder.decode(value, { stream: true }); if (text.includes(`${tag} OK`) || text.includes(`${tag} NO`) || text.includes(`${tag} BAD`)) break; } return text; } async function writeCommand(writer: WritableStreamDefaultWriter, tag: string, command: string) { await writer.write(new TextEncoder().encode(`${tag} ${command}\r\n`)); } function parseHeaderBlock(block: string) { const get = (name: string) => { const match = block.match(new RegExp(`^${name}:\\s*([\\s\\S]*?)(?=\\r?\\n[A-Za-z-]+:|$)`, "im")); return (match?.[1] || "").replace(/\r?\n\s+/g, " ").trim(); }; const uid = block.match(/UID\s+(\d+)/i)?.[1] || crypto.randomUUID(); const flags = block.match(/FLAGS\s+\(([^)]*)\)/i)?.[1] || ""; return { id: uid, from: get("From") || "Unknown sender", subject: get("Subject") || "(no subject)", date: get("Date"), unread: !/\\Seen/i.test(flags), }; } function parseMessages(fetchResponse: string) { return fetchResponse .split(/\r?\n\)\r?\n(?=\* \d+ FETCH|[A-Z]\d+ OK|$)/g) .filter((block) => /FETCH/i.test(block)) .map(parseHeaderBlock) .filter((message) => message.from || message.subject) .reverse(); } function filterMessagesByOpenedAt(messages: ReturnType, openedSince?: string) { if (!openedSince) return messages; const openedAt = new Date(openedSince).getTime(); if (!Number.isFinite(openedAt)) return messages; return messages.filter((message) => { const receivedAt = new Date(message.date).getTime(); return Number.isFinite(receivedAt) && receivedAt >= openedAt; }); } async function fetchImapMessages(config: any, openedSince?: string) { const conn = config.use_tls ? await Deno.connectTls({ hostname: config.imap_host, port: Number(config.imap_port || 993) }) : await Deno.connect({ hostname: config.imap_host, port: Number(config.imap_port || 143) }); const reader = conn.readable.getReader(); const writer = conn.writable.getWriter(); await readUntil(reader, "*"); await writeCommand(writer, "A1", `LOGIN ${quote(config.imap_username)} ${quote(config.imap_password)}`); const login = await readUntil(reader, "A1"); if (!login.includes("A1 OK")) throw new Error("IMAP login failed. Check username, password, host, and port."); await writeCommand(writer, "A2", "SELECT INBOX"); const selected = await readUntil(reader, "A2"); const exists = Number(selected.match(/\*\s+(\d+)\s+EXISTS/i)?.[1] || 0); if (exists === 0) { await writeCommand(writer, "A4", "LOGOUT"); await readUntil(reader, "A4", 3000).catch(() => ""); conn.close(); return []; } const start = Math.max(1, exists - 49); await writeCommand(writer, "A3", `FETCH ${start}:${exists} (UID FLAGS BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])`); const fetched = await readUntil(reader, "A3"); await writeCommand(writer, "A4", "LOGOUT"); await readUntil(reader, "A4", 3000).catch(() => ""); conn.close(); return filterMessagesByOpenedAt(parseMessages(fetched), openedSince); } Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); try { const auth = await getAuthorizedClient(req); if (auth.error) return auth.error; const { client, callerId } = auth; const body = await req.json().catch(() => ({})); const action = String(body.action || "fetch_messages"); if (action === "list_configs") { const { data, error } = await client.from("email_inbox_configs").select("id, display_name, email_address, imap_host, imap_port, imap_username, use_tls, is_active, created_at").order("created_at", { ascending: false }); if (error) throw error; return jsonResponse({ success: true, configs: data || [] }); } if (action === "save_config") { const config = body.config || {}; if (!config.display_name || !config.email_address || !config.imap_host || !config.imap_username || !config.imap_password) { return jsonResponse({ success: false, error: "Display name, email, IMAP host, username, and password are required." }, 400); } const payload = { user_id: callerId, display_name: String(config.display_name), email_address: String(config.email_address).toLowerCase(), imap_host: String(config.imap_host), imap_port: Number(config.imap_port || 993), imap_username: String(config.imap_username), imap_password: String(config.imap_password), use_tls: config.use_tls !== false, is_active: config.is_active !== false, }; const query = config.id ? client.from("email_inbox_configs").update(payload).eq("id", config.id).select("id").single() : client.from("email_inbox_configs").insert(payload).select("id").single(); const { data, error } = await query; if (error) throw error; return jsonResponse({ success: true, id: data.id }); } if (action === "delete_config") { if (!body.id) return jsonResponse({ success: false, error: "Inbox config id is required." }, 400); const { error } = await client.from("email_inbox_configs").delete().eq("id", body.id); if (error) throw error; return jsonResponse({ success: true }); } const configId = body.config_id; const query = client.from("email_inbox_configs").select("*").eq("is_active", true).order("created_at", { ascending: false }).limit(1); const { data: configs, error } = configId ? await client.from("email_inbox_configs").select("*").eq("id", configId).limit(1) : await query; if (error) throw error; const config = configs?.[0]; if (!config) return jsonResponse({ success: true, messages: [], configs: [] }); const messages = await fetchImapMessages(config, typeof body.opened_since === "string" ? body.opened_since : undefined); return jsonResponse({ success: true, messages, mailbox: { id: config.id, display_name: config.display_name, email_address: config.email_address } }); } catch (error) { console.error("[fetch-imap-inbox]", error); return jsonResponse({ success: false, error: error instanceof Error ? error.message : "Failed to fetch inbox" }, 500); } });