553 lines
18 KiB
TypeScript
553 lines
18 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";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
|
|
|
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();
|
|
|
|
// ── Helper: get or create user by DID ──
|
|
async function getOrCreateUser(did: string, username?: string) {
|
|
const rows = await sql.unsafe(
|
|
`INSERT INTO rinbox.users (did, username) VALUES ($1, $2)
|
|
ON CONFLICT (did) DO UPDATE SET username = COALESCE($2, rinbox.users.username)
|
|
RETURNING *`,
|
|
[did, username || null]
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
// ── 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 token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
const body = await c.req.json();
|
|
const { slug, name, email, description, visibility = "members_only", imap_user, imap_password } = 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, imap_user)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
|
[slug, name, email, description || null, visibility, claims.sub, imap_user || null]
|
|
);
|
|
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 token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
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);
|
|
|
|
const user = await getOrCreateUser(claims.sub, claims.username);
|
|
|
|
const rows = await sql.unsafe(
|
|
`INSERT INTO rinbox.comments (thread_id, author_id, body, mentions)
|
|
VALUES ($1, $2, $3, $4) RETURNING *`,
|
|
[threadId, user.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 token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
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 getOrCreateUser(claims.sub, claims.username);
|
|
|
|
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.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 token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
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 getOrCreateUser(claims.sub, claims.username);
|
|
|
|
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.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 token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
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, claims.sub]
|
|
);
|
|
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, imapSync: IMAP_HOST !== "" }));
|
|
|
|
// GET /api/sync-status — show IMAP sync state per mailbox
|
|
routes.get("/api/sync-status", async (c) => {
|
|
const rows = await sql.unsafe(
|
|
`SELECT s.*, m.slug, m.name, m.email
|
|
FROM rinbox.sync_state s
|
|
JOIN rinbox.mailboxes m ON m.id = s.mailbox_id
|
|
ORDER BY s.last_sync_at DESC NULLS LAST`
|
|
);
|
|
return c.json({ syncStates: rows });
|
|
});
|
|
|
|
// ── IMAP Sync Worker ──
|
|
const IMAP_HOST = process.env.IMAP_HOST || "";
|
|
const IMAP_PORT = parseInt(process.env.IMAP_PORT || "993");
|
|
const IMAP_TLS_REJECT = process.env.IMAP_TLS_REJECT_UNAUTHORIZED !== "false";
|
|
const SYNC_INTERVAL = 30_000; // 30 seconds
|
|
|
|
async function syncMailbox(mailbox: any) {
|
|
let ImapFlow: any;
|
|
let simpleParser: any;
|
|
try {
|
|
ImapFlow = (await import("imapflow")).ImapFlow;
|
|
simpleParser = (await import("mailparser")).simpleParser;
|
|
} catch {
|
|
console.error("[Inbox] imapflow/mailparser not installed — skipping IMAP sync");
|
|
return;
|
|
}
|
|
|
|
const host = mailbox.imap_host || IMAP_HOST;
|
|
const port = mailbox.imap_port || IMAP_PORT;
|
|
const user = mailbox.imap_user;
|
|
if (!host || !user) return;
|
|
|
|
// Get or create sync state
|
|
let syncState = (await sql.unsafe(
|
|
"SELECT * FROM rinbox.sync_state WHERE mailbox_id = $1",
|
|
[mailbox.id]
|
|
))[0];
|
|
|
|
if (!syncState) {
|
|
const rows = await sql.unsafe(
|
|
"INSERT INTO rinbox.sync_state (mailbox_id) VALUES ($1) RETURNING *",
|
|
[mailbox.id]
|
|
);
|
|
syncState = rows[0];
|
|
}
|
|
|
|
const client = new ImapFlow({
|
|
host,
|
|
port,
|
|
secure: port === 993,
|
|
auth: { user, pass: process.env[`IMAP_PASS_${mailbox.slug.toUpperCase().replace(/-/g, "_")}`] || "" },
|
|
tls: { rejectUnauthorized: IMAP_TLS_REJECT },
|
|
logger: false,
|
|
});
|
|
|
|
try {
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock("INBOX");
|
|
|
|
try {
|
|
const status = client.mailbox;
|
|
const uidValidity = status?.uidValidity;
|
|
|
|
// UID validity changed — need full resync
|
|
if (syncState.uid_validity && uidValidity && syncState.uid_validity !== uidValidity) {
|
|
await sql.unsafe(
|
|
"UPDATE rinbox.sync_state SET last_uid = 0, uid_validity = $1 WHERE mailbox_id = $2",
|
|
[uidValidity, mailbox.id]
|
|
);
|
|
syncState.last_uid = 0;
|
|
}
|
|
|
|
// Fetch messages newer than last synced UID
|
|
const lastUid = syncState.last_uid || 0;
|
|
const range = lastUid > 0 ? `${lastUid + 1}:*` : "1:*";
|
|
let maxUid = lastUid;
|
|
let count = 0;
|
|
|
|
for await (const msg of client.fetch(range, {
|
|
envelope: true,
|
|
source: true,
|
|
uid: true,
|
|
})) {
|
|
if (msg.uid <= lastUid) continue;
|
|
if (count >= 100) break; // Batch limit
|
|
|
|
try {
|
|
const parsed = await simpleParser(msg.source);
|
|
const fromAddr = parsed.from?.value?.[0]?.address || msg.envelope?.from?.[0]?.address || "";
|
|
const fromName = parsed.from?.value?.[0]?.name || msg.envelope?.from?.[0]?.name || "";
|
|
const subject = parsed.subject || msg.envelope?.subject || "(no subject)";
|
|
const toAddrs = (parsed.to?.value || []).map((a: any) => ({ address: a.address, name: a.name }));
|
|
const ccAddrs = (parsed.cc?.value || []).map((a: any) => ({ address: a.address, name: a.name }));
|
|
const messageId = parsed.messageId || msg.envelope?.messageId || null;
|
|
const hasAttachments = (parsed.attachments?.length || 0) > 0;
|
|
|
|
await sql.unsafe(
|
|
`INSERT INTO rinbox.threads (mailbox_id, message_id, subject, from_address, from_name, to_addresses, cc_addresses, body_text, body_html, has_attachments, received_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10, $11)
|
|
ON CONFLICT DO NOTHING`,
|
|
[
|
|
mailbox.id,
|
|
messageId,
|
|
subject,
|
|
fromAddr,
|
|
fromName,
|
|
JSON.stringify(toAddrs),
|
|
JSON.stringify(ccAddrs),
|
|
parsed.text || null,
|
|
parsed.html || null,
|
|
hasAttachments,
|
|
parsed.date || new Date(),
|
|
]
|
|
);
|
|
count++;
|
|
} catch (parseErr) {
|
|
console.error(`[Inbox] Parse error UID ${msg.uid}:`, parseErr);
|
|
}
|
|
|
|
if (msg.uid > maxUid) maxUid = msg.uid;
|
|
}
|
|
|
|
// Update sync state
|
|
await sql.unsafe(
|
|
"UPDATE rinbox.sync_state SET last_uid = $1, uid_validity = $2, last_sync_at = NOW(), error = NULL WHERE mailbox_id = $3",
|
|
[maxUid, uidValidity || null, mailbox.id]
|
|
);
|
|
|
|
if (count > 0) console.log(`[Inbox] Synced ${count} messages for ${mailbox.email}`);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
|
|
await client.logout();
|
|
} catch (e: any) {
|
|
console.error(`[Inbox] IMAP sync error for ${mailbox.email}:`, e.message);
|
|
await sql.unsafe(
|
|
"UPDATE rinbox.sync_state SET error = $1, last_sync_at = NOW() WHERE mailbox_id = $2",
|
|
[e.message, mailbox.id]
|
|
).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function runSyncLoop() {
|
|
if (!IMAP_HOST) {
|
|
console.log("[Inbox] IMAP_HOST not set — IMAP sync disabled");
|
|
return;
|
|
}
|
|
console.log(`[Inbox] IMAP sync enabled — polling every ${SYNC_INTERVAL / 1000}s`);
|
|
|
|
const doSync = async () => {
|
|
try {
|
|
const mailboxes = await sql.unsafe(
|
|
"SELECT * FROM rinbox.mailboxes WHERE imap_user IS NOT NULL"
|
|
);
|
|
for (const mb of mailboxes) {
|
|
await syncMailbox(mb);
|
|
}
|
|
} catch (e) {
|
|
console.error("[Inbox] Sync loop error:", e);
|
|
}
|
|
};
|
|
|
|
// Initial sync after 5s delay
|
|
setTimeout(doSync, 5000);
|
|
// Recurring sync
|
|
setInterval(doSync, SYNC_INTERVAL);
|
|
}
|
|
|
|
// Start IMAP sync in background
|
|
runSyncLoop();
|
|
|
|
// ── 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",
|
|
};
|