/** * Inbox module — collaborative email with multisig approval. * * Shared mailboxes with role-based access, threaded comments, * and Gnosis Safe multisig approval for outgoing emails. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; const routes = new Hono(); // ── DB initialization ── const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Inbox] DB schema initialized"); } catch (e) { console.error("[Inbox] DB init error:", e); } } initDB(); // ── Mailboxes API ── // GET /api/mailboxes — list mailboxes routes.get("/api/mailboxes", async (c) => { const { workspace } = c.req.query(); let rows; if (workspace) { rows = await sql.unsafe( `SELECT m.* FROM rinbox.mailboxes m JOIN rinbox.workspaces w ON w.id = m.workspace_id WHERE w.slug = $1 ORDER BY m.created_at DESC`, [workspace] ); } else { rows = await sql.unsafe( "SELECT * FROM rinbox.mailboxes ORDER BY created_at DESC LIMIT 50" ); } return c.json({ mailboxes: rows }); }); // POST /api/mailboxes — create mailbox routes.post("/api/mailboxes", async (c) => { const body = await c.req.json(); const { slug, name, email, description, visibility = "members_only" } = body; if (!slug || !name || !email) return c.json({ error: "slug, name, email required" }, 400); if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400); try { const rows = await sql.unsafe( `INSERT INTO rinbox.mailboxes (slug, name, email, description, visibility, owner_did) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [slug, name, email, description || null, visibility, "anonymous"] ); return c.json(rows[0], 201); } catch (e: any) { if (e.code === "23505") return c.json({ error: "Mailbox already exists" }, 409); throw e; } }); // GET /api/mailboxes/:slug — mailbox detail routes.get("/api/mailboxes/:slug", async (c) => { const slug = c.req.param("slug"); const rows = await sql.unsafe("SELECT * FROM rinbox.mailboxes WHERE slug = $1", [slug]); if (rows.length === 0) return c.json({ error: "Mailbox not found" }, 404); // Get thread count const counts = await sql.unsafe( `SELECT status, count(*) as cnt FROM rinbox.threads WHERE mailbox_id = $1 GROUP BY status`, [rows[0].id] ); const threadCounts: Record = {}; for (const row of counts) threadCounts[row.status] = parseInt(row.cnt); return c.json({ ...rows[0], threadCounts }); }); // ── Threads API ── // GET /api/mailboxes/:slug/threads — list threads routes.get("/api/mailboxes/:slug/threads", async (c) => { const slug = c.req.param("slug"); const { status, search, limit = "50", offset = "0" } = c.req.query(); const mailbox = await sql.unsafe("SELECT id FROM rinbox.mailboxes WHERE slug = $1", [slug]); if (mailbox.length === 0) return c.json({ error: "Mailbox not found" }, 404); const conditions = ["mailbox_id = $1"]; const params: unknown[] = [mailbox[0].id]; let idx = 2; if (status) { conditions.push(`status = $${idx}`); params.push(status); idx++; } if (search) { conditions.push(`(subject ILIKE $${idx} OR from_address ILIKE $${idx})`); params.push(`%${search}%`); idx++; } const where = conditions.join(" AND "); const rows = await sql.unsafe( `SELECT t.*, (SELECT count(*) FROM rinbox.comments WHERE thread_id = t.id) as comment_count FROM rinbox.threads t WHERE ${where} ORDER BY t.received_at DESC LIMIT ${Math.min(parseInt(limit), 100)} OFFSET ${parseInt(offset) || 0}`, params ); return c.json({ threads: rows }); }); // GET /api/threads/:id — thread detail with comments routes.get("/api/threads/:id", async (c) => { const id = c.req.param("id"); const rows = await sql.unsafe("SELECT * FROM rinbox.threads WHERE id = $1", [id]); if (rows.length === 0) return c.json({ error: "Thread not found" }, 404); const comments = await sql.unsafe( `SELECT c.*, u.username, u.did as author_did FROM rinbox.comments c LEFT JOIN rinbox.users u ON u.id = c.author_id WHERE c.thread_id = $1 ORDER BY c.created_at ASC`, [id] ); return c.json({ ...rows[0], comments }); }); // PATCH /api/threads/:id — update thread metadata routes.patch("/api/threads/:id", async (c) => { const id = c.req.param("id"); const body = await c.req.json(); const allowed = ["status", "is_read", "is_starred", "tags", "assigned_to"]; const updates: string[] = []; const params: unknown[] = []; let idx = 1; for (const key of allowed) { if (key in body) { const col = key === "tags" ? "tags" : key; updates.push(`${col} = $${idx}`); params.push(key === "tags" ? body[key] : body[key]); idx++; } } if (updates.length === 0) return c.json({ error: "No valid fields" }, 400); params.push(id); const rows = await sql.unsafe( `UPDATE rinbox.threads SET ${updates.join(", ")} WHERE id = $${idx} RETURNING *`, params ); if (rows.length === 0) return c.json({ error: "Thread not found" }, 404); return c.json(rows[0]); }); // POST /api/threads/:id/comments — add comment routes.post("/api/threads/:id/comments", async (c) => { const threadId = c.req.param("id"); const body = await c.req.json(); const { text, mentions } = body; if (!text) return c.json({ error: "text required" }, 400); // Ensure thread exists const thread = await sql.unsafe("SELECT id FROM rinbox.threads WHERE id = $1", [threadId]); if (thread.length === 0) return c.json({ error: "Thread not found" }, 404); // Get or create anonymous user const user = await sql.unsafe( `INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous') ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id` ); const rows = await sql.unsafe( `INSERT INTO rinbox.comments (thread_id, author_id, body, mentions) VALUES ($1, $2, $3, $4) RETURNING *`, [threadId, user[0].id, text, mentions || []] ); return c.json(rows[0], 201); }); // ── Approvals API ── // GET /api/approvals — list pending approvals routes.get("/api/approvals", async (c) => { const { mailbox, status = "PENDING" } = c.req.query(); let rows; if (mailbox) { const mb = await sql.unsafe("SELECT id FROM rinbox.mailboxes WHERE slug = $1", [mailbox]); if (mb.length === 0) return c.json({ error: "Mailbox not found" }, 404); rows = await sql.unsafe( `SELECT a.*, (SELECT count(*) FROM rinbox.approval_signatures WHERE approval_id = a.id) as signature_count FROM rinbox.approvals a WHERE a.mailbox_id = $1 AND a.status = $2 ORDER BY a.created_at DESC`, [mb[0].id, status] ); } else { rows = await sql.unsafe( `SELECT a.*, (SELECT count(*) FROM rinbox.approval_signatures WHERE approval_id = a.id) as signature_count FROM rinbox.approvals a WHERE a.status = $1 ORDER BY a.created_at DESC LIMIT 50`, [status] ); } return c.json({ approvals: rows }); }); // POST /api/approvals — create approval draft routes.post("/api/approvals", async (c) => { const body = await c.req.json(); const { mailbox_slug, thread_id, subject, body_text, to_addresses } = body; if (!mailbox_slug || !subject) return c.json({ error: "mailbox_slug and subject required" }, 400); const mb = await sql.unsafe("SELECT * FROM rinbox.mailboxes WHERE slug = $1", [mailbox_slug]); if (mb.length === 0) return c.json({ error: "Mailbox not found" }, 404); const user = await sql.unsafe( `INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous') ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id` ); const rows = await sql.unsafe( `INSERT INTO rinbox.approvals (mailbox_id, thread_id, author_id, subject, body_text, to_addresses, required_signatures) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [mb[0].id, thread_id || null, user[0].id, subject, body_text || null, JSON.stringify(to_addresses || []), mb[0].approval_threshold || 1] ); return c.json(rows[0], 201); }); // POST /api/approvals/:id/sign — sign an approval routes.post("/api/approvals/:id/sign", async (c) => { const id = c.req.param("id"); const body = await c.req.json(); const { vote = "APPROVE" } = body; if (!["APPROVE", "REJECT"].includes(vote)) return c.json({ error: "Invalid vote" }, 400); const approval = await sql.unsafe("SELECT * FROM rinbox.approvals WHERE id = $1", [id]); if (approval.length === 0) return c.json({ error: "Approval not found" }, 404); if (approval[0].status !== "PENDING") return c.json({ error: "Approval not pending" }, 400); const user = await sql.unsafe( `INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous') ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id` ); await sql.unsafe( `INSERT INTO rinbox.approval_signatures (approval_id, signer_id, vote) VALUES ($1, $2, $3) ON CONFLICT (approval_id, signer_id) DO UPDATE SET vote = $3, signed_at = NOW()`, [id, user[0].id, vote] ); // Check if threshold reached const sigs = await sql.unsafe( "SELECT count(*) as cnt FROM rinbox.approval_signatures WHERE approval_id = $1 AND vote = 'APPROVE'", [id] ); const approveCount = parseInt(sigs[0].cnt); if (approveCount >= approval[0].required_signatures) { await sql.unsafe( "UPDATE rinbox.approvals SET status = 'APPROVED', resolved_at = NOW() WHERE id = $1", [id] ); return c.json({ ok: true, status: "APPROVED", signatures: approveCount }); } // Check for rejection (more rejects than possible remaining approvals) const rejects = await sql.unsafe( "SELECT count(*) as cnt FROM rinbox.approval_signatures WHERE approval_id = $1 AND vote = 'REJECT'", [id] ); const rejectCount = parseInt(rejects[0].cnt); if (rejectCount > 0) { await sql.unsafe( "UPDATE rinbox.approvals SET status = 'REJECTED', resolved_at = NOW() WHERE id = $1", [id] ); return c.json({ ok: true, status: "REJECTED", signatures: approveCount }); } return c.json({ ok: true, status: "PENDING", signatures: approveCount, required: approval[0].required_signatures }); }); // ── Workspaces API ── // GET /api/workspaces routes.get("/api/workspaces", async (c) => { const rows = await sql.unsafe("SELECT * FROM rinbox.workspaces ORDER BY created_at DESC LIMIT 50"); return c.json({ workspaces: rows }); }); // POST /api/workspaces routes.post("/api/workspaces", async (c) => { const body = await c.req.json(); const { slug, name, description } = body; if (!slug || !name) return c.json({ error: "slug and name required" }, 400); try { const rows = await sql.unsafe( `INSERT INTO rinbox.workspaces (slug, name, description, owner_did) VALUES ($1, $2, $3, $4) RETURNING *`, [slug, name, description || null, "anonymous"] ); return c.json(rows[0], 201); } catch (e: any) { if (e.code === "23505") return c.json({ error: "Workspace already exists" }, 409); throw e; } }); // GET /api/health routes.get("/api/health", (c) => c.json({ ok: true })); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Inbox | rSpace`, moduleId: "inbox", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const inboxModule: RSpaceModule = { id: "inbox", name: "rInbox", icon: "\u{1F4E8}", description: "Collaborative email with multisig approval", routes, standaloneDomain: "rinbox.online", };