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>
180 lines
8.0 KiB
TypeScript
180 lines
8.0 KiB
TypeScript
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<Uint8Array>, 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<Uint8Array>, 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<typeof parseMessages>, 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);
|
|
}
|
|
}); |