// 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 }; });