diff --git a/src/lib/mail.functions.ts b/src/lib/mail.functions.ts new file mode 100644 index 0000000..fef716f --- /dev/null +++ b/src/lib/mail.functions.ts @@ -0,0 +1,430 @@ +// 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 }; + });