mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user