diff --git a/src/lib/mail.functions.ts b/src/lib/mail.functions.ts deleted file mode 100644 index fef716f..0000000 --- a/src/lib/mail.functions.ts +++ /dev/null @@ -1,430 +0,0 @@ -// Embedded mail. IMAP and SMTP are raw TCP protocols, so every one of these -// runs server-side; the browser only ever sees parsed results. -// -// This file ships to the client bundle, so imapflow / nodemailer / mailparser / -// the crypto helpers are imported *inside* handlers, never at the top level. -import { createServerFn } from "@tanstack/react-start"; -import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; - -export type MailSettings = { - imap_host: string; - imap_port: number; - imap_secure: boolean; - smtp_host: string; - smtp_port: number; - smtp_secure: boolean; -}; - -export type MailboxSummary = { - user_id: string; - email: string; - full_name: string | null; - last_verified_at: string | null; -}; - -export type MessageHeader = { - uid: number; - subject: string; - from: string; - to: string; - date: string | null; - seen: boolean; - flagged: boolean; - hasAttachments: boolean; -}; - -export type MessageBody = { - uid: number; - subject: string; - from: string; - to: string; - cc: string; - date: string | null; - text: string; - html: string | null; - messageId: string | null; - references: string | null; - attachments: { filename: string; size: number; contentType: string }[]; -}; - -const callerId = (context: unknown) => (context as { userId: string }).userId; - -async function assertAdmin(uid: string) { - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { data } = await supabaseAdmin - .from("user_roles") - .select("role") - .eq("user_id", uid) - .eq("role", "admin") - .maybeSingle(); - if (!data) throw new Error("Only admins can manage mail settings."); -} - -async function loadSettings(): Promise { - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { data } = await supabaseAdmin.from("mail_server_settings").select("*").maybeSingle(); - if (!data) throw new Error("Mail server is not configured yet. An admin must set it up first."); - return data as MailSettings; -} - -// Resolves the caller's own mailbox credentials. Nothing here is ever returned -// to the client — only used to open a connection. -async function credentialsFor(userId: string) { - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { open } = await import("@/lib/mail-crypto.server"); - const { data } = await supabaseAdmin - .from("user_mailboxes") - .select("email, secret_ciphertext, secret_iv, secret_tag") - .eq("user_id", userId) - .maybeSingle(); - if (!data) throw new Error("You do not have a mailbox yet. Ask an administrator to set one up."); - const settings = await loadSettings(); - return { - settings, - email: data.email, - password: open({ - ciphertext: data.secret_ciphertext, - iv: data.secret_iv, - tag: data.secret_tag, - }), - }; -} - -async function withImap( - userId: string, - fn: (client: import("imapflow").ImapFlow) => Promise, -): Promise { - const { ImapFlow } = await import("imapflow"); - const { settings, email, password } = await credentialsFor(userId); - const client = new ImapFlow({ - host: settings.imap_host, - port: settings.imap_port, - secure: settings.imap_secure, - auth: { user: email, pass: password }, - logger: false, - // Fail fast rather than hanging a request behind a dead mail host. - socketTimeout: 20_000, - greetingTimeout: 10_000, - }); - await client.connect(); - try { - return await fn(client); - } finally { - await client.logout().catch(() => client.close()); - } -} - -const addr = (a: unknown): string => { - const list = (a as { address?: string; name?: string }[] | undefined) ?? []; - return list.map((x) => (x.name ? `${x.name} <${x.address}>` : (x.address ?? ""))).join(", "); -}; - -// ── Admin: setup ──────────────────────────────────────────────────────────── -export const getMailSetup = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .handler(async ({ context }) => { - const uid = callerId(context); - await assertAdmin(uid); - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { mailKeyConfigured } = await import("@/lib/mail-crypto.server"); - - const { data: settings } = await supabaseAdmin - .from("mail_server_settings") - .select("*") - .maybeSingle(); - const { data: boxes } = await supabaseAdmin - .from("user_mailboxes") - .select("user_id, email, last_verified_at"); - const ids = (boxes ?? []).map((b) => b.user_id); - const { data: profs } = ids.length - ? await supabaseAdmin.from("profiles").select("id, full_name").in("id", ids) - : { data: [] as { id: string; full_name: string | null }[] }; - const nameOf = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name])); - - // Staff eligible for a mailbox. Students are deliberately excluded from this - // rollout; widening it is a policy decision, not a code change. - const { data: staffRoles } = await supabaseAdmin - .from("user_roles") - .select("user_id, role") - .in("role", ["admin", "teacher"]); - const staffIds = [...new Set((staffRoles ?? []).map((r) => r.user_id))]; - const { data: staffProfiles } = staffIds.length - ? await supabaseAdmin.from("profiles").select("id, full_name, email").in("id", staffIds) - : { data: [] as { id: string; full_name: string | null; email: string | null }[] }; - - return { - settings: (settings ?? null) as MailSettings | null, - keyConfigured: mailKeyConfigured(), - mailboxes: (boxes ?? []).map((b) => ({ - user_id: b.user_id, - email: b.email, - full_name: nameOf[b.user_id] ?? null, - last_verified_at: b.last_verified_at, - })) as MailboxSummary[], - staff: (staffProfiles ?? []) as { - id: string; - full_name: string | null; - email: string | null; - }[], - }; - }); - -export const saveMailSettings = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .validator((d: MailSettings) => d) - .handler(async ({ data, context }) => { - const uid = callerId(context); - await assertAdmin(uid); - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { error } = await supabaseAdmin.from("mail_server_settings").upsert( - { - id: true, - imap_host: data.imap_host.trim(), - imap_port: Number(data.imap_port), - imap_secure: !!data.imap_secure, - smtp_host: data.smtp_host.trim(), - smtp_port: Number(data.smtp_port), - smtp_secure: !!data.smtp_secure, - updated_by: uid, - }, - { onConflict: "id" }, - ); - if (error) throw new Error(error.message); - return { ok: true }; - }); - -// Provisioning verifies the credentials against the live IMAP server before -// storing them, so a typo surfaces here instead of as a broken inbox later. -export const setUserMailbox = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .validator((d: { userId: string; email: string; password: string }) => d) - .handler(async ({ data, context }) => { - const uid = callerId(context); - await assertAdmin(uid); - if (!data.email.trim() || !data.password) - throw new Error("Email and password are both required."); - - const settings = await loadSettings(); - const { ImapFlow } = await import("imapflow"); - const probe = new ImapFlow({ - host: settings.imap_host, - port: settings.imap_port, - secure: settings.imap_secure, - auth: { user: data.email.trim(), pass: data.password }, - logger: false, - socketTimeout: 20_000, - greetingTimeout: 10_000, - }); - try { - await probe.connect(); - await probe.logout(); - } catch (e) { - throw new Error(`IMAP login failed: ${(e as Error).message}`); - } - - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { seal } = await import("@/lib/mail-crypto.server"); - const s = seal(data.password); - const { error } = await supabaseAdmin.from("user_mailboxes").upsert( - { - user_id: data.userId, - email: data.email.trim(), - secret_ciphertext: s.ciphertext, - secret_iv: s.iv, - secret_tag: s.tag, - last_verified_at: new Date().toISOString(), - created_by: uid, - }, - { onConflict: "user_id" }, - ); - if (error) throw new Error(error.message); - return { ok: true }; - }); - -export const removeUserMailbox = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .validator((d: { userId: string }) => d) - .handler(async ({ data, context }) => { - const uid = callerId(context); - await assertAdmin(uid); - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { error } = await supabaseAdmin - .from("user_mailboxes") - .delete() - .eq("user_id", data.userId); - if (error) throw new Error(error.message); - return { ok: true }; - }); - -// ── Own mailbox ───────────────────────────────────────────────────────────── -export const myMailboxStatus = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .handler(async ({ context }) => { - const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { data: box } = await supabaseAdmin - .from("user_mailboxes") - .select("email") - .eq("user_id", callerId(context)) - .maybeSingle(); - const { data: settings } = await supabaseAdmin - .from("mail_server_settings") - .select("id") - .maybeSingle(); - return { email: box?.email ?? null, serverConfigured: !!settings }; - }); - -export const listFolders = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .handler(async ({ context }) => - withImap(callerId(context), async (client) => { - const list = await client.list(); - return list - .filter((f) => !f.flags.has("\\Noselect")) - .map((f) => ({ path: f.path, name: f.name, specialUse: f.specialUse ?? null })); - }), - ); - -export const listMessages = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .validator((d: { folder?: string; limit?: number }) => d) - .handler(async ({ data, context }) => - withImap(callerId(context), async (client) => { - const folder = data.folder || "INBOX"; - const limit = Math.min(Math.max(data.limit ?? 40, 1), 100); - const lock = await client.getMailboxLock(folder); - try { - const total = typeof client.mailbox === "object" ? client.mailbox.exists : 0; - if (!total) return { folder, total: 0, messages: [] as MessageHeader[] }; - // Newest `limit` by sequence number, then reversed for display. - const start = Math.max(1, total - limit + 1); - const out: MessageHeader[] = []; - for await (const m of client.fetch(`${start}:${total}`, { - uid: true, - envelope: true, - flags: true, - bodyStructure: true, - })) { - const bs = m.bodyStructure as { childNodes?: { disposition?: string }[] } | undefined; - out.push({ - uid: m.uid, - subject: m.envelope?.subject ?? "(no subject)", - from: addr(m.envelope?.from), - to: addr(m.envelope?.to), - date: m.envelope?.date ? new Date(m.envelope.date).toISOString() : null, - seen: m.flags?.has("\\Seen") ?? false, - flagged: m.flags?.has("\\Flagged") ?? false, - hasAttachments: !!bs?.childNodes?.some((c) => c.disposition === "attachment"), - }); - } - return { folder, total, messages: out.reverse() }; - } finally { - lock.release(); - } - }), - ); - -export const getMessage = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .validator((d: { folder: string; uid: number }) => d) - .handler(async ({ data, context }) => - withImap(callerId(context), async (client) => { - const lock = await client.getMailboxLock(data.folder || "INBOX"); - try { - const msg = await client.fetchOne(String(data.uid), { source: true }, { uid: true }); - if (!msg || !msg.source) throw new Error("Message not found."); - const { simpleParser } = await import("mailparser"); - const p = await simpleParser(msg.source); - // Opening a message marks it read, matching every other mail client. - await client.messageFlagsAdd(String(data.uid), ["\\Seen"], { uid: true }).catch(() => {}); - return { - uid: data.uid, - subject: p.subject ?? "(no subject)", - from: p.from?.text ?? "", - to: Array.isArray(p.to) ? p.to.map((t) => t.text).join(", ") : (p.to?.text ?? ""), - cc: Array.isArray(p.cc) ? p.cc.map((t) => t.text).join(", ") : (p.cc?.text ?? ""), - date: p.date ? p.date.toISOString() : null, - text: p.text ?? "", - html: typeof p.html === "string" ? p.html : null, - messageId: p.messageId ?? null, - references: Array.isArray(p.references) ? p.references.join(" ") : (p.references ?? null), - attachments: (p.attachments ?? []).map((a) => ({ - filename: a.filename ?? "attachment", - size: a.size ?? 0, - contentType: a.contentType ?? "application/octet-stream", - })), - } as MessageBody; - } finally { - lock.release(); - } - }), - ); - -export const getAttachment = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .validator((d: { folder: string; uid: number; filename: string }) => d) - .handler(async ({ data, context }) => - withImap(callerId(context), async (client) => { - const lock = await client.getMailboxLock(data.folder || "INBOX"); - try { - const msg = await client.fetchOne(String(data.uid), { source: true }, { uid: true }); - if (!msg || !msg.source) throw new Error("Message not found."); - const { simpleParser } = await import("mailparser"); - const p = await simpleParser(msg.source); - const found = (p.attachments ?? []).find( - (a) => (a.filename ?? "attachment") === data.filename, - ); - if (!found) throw new Error("Attachment not found."); - return { - filename: data.filename, - contentType: found.contentType ?? "application/octet-stream", - base64: Buffer.from(found.content).toString("base64"), - }; - } finally { - lock.release(); - } - }), - ); - -export const sendMail = createServerFn({ method: "POST" }) - .middleware([requireSupabaseAuth]) - .validator( - (d: { - to: string; - cc?: string; - subject: string; - body: string; - inReplyTo?: string | null; - references?: string | null; - }) => d, - ) - .handler(async ({ data, context }) => { - const uid = callerId(context); - const { settings, email, password } = await credentialsFor(uid); - if (!data.to.trim()) throw new Error("At least one recipient is required."); - - const nodemailer = await import("nodemailer"); - const transport = nodemailer.createTransport({ - host: settings.smtp_host, - port: settings.smtp_port, - secure: settings.smtp_secure, // false = STARTTLS on 587 - auth: { user: email, pass: password }, - connectionTimeout: 20_000, - }); - - const info = await transport.sendMail({ - from: email, - to: data.to, - cc: data.cc || undefined, - subject: data.subject, - text: data.body, - // Threading headers so replies land in the original conversation. - inReplyTo: data.inReplyTo || undefined, - references: data.references || undefined, - }); - - // Deliberately not appending a copy to the Sent folder. Hosted providers - // (Migadu, Zoho, Google, Fastmail) file SMTP-submitted mail into Sent - // themselves, so appending would show every sent message twice. If the - // chosen provider turns out not to file sent mail, add an IMAP append here. - return { ok: true, messageId: info.messageId }; - });