353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
/**
|
|
* 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<string, number> = {};
|
|
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: `<link rel="stylesheet" href="/modules/inbox/inbox.css">`,
|
|
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`,
|
|
scripts: `<script type="module" src="/modules/inbox/folk-inbox-client.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const inboxModule: RSpaceModule = {
|
|
id: "inbox",
|
|
name: "rInbox",
|
|
icon: "\u{1F4E8}",
|
|
description: "Collaborative email with multisig approval",
|
|
routes,
|
|
standaloneDomain: "rinbox.online",
|
|
};
|