/** * Inbox module — collaborative email with multisig approval. * * Shared mailboxes with role-based access, threaded comments, * and Gnosis Safe multisig approval for outgoing emails. * * Storage: Automerge documents via SyncServer (one doc per mailbox). * IMAP credentials and sync state kept in module-scoped Maps (not in CRDT). */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { mailboxSchema, mailboxDocId, type MailboxDoc, type MailboxMeta, type ThreadItem, type ThreadComment, type ApprovalItem, type ApprovalSignature, } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── In-memory stores for data not in Automerge schemas ── /** Workspace metadata (no Automerge schema — lightweight index) */ interface WorkspaceInfo { id: string; slug: string; name: string; description: string | null; ownerDid: string; createdAt: number; } const _workspaces = new Map(); // slug → info /** IMAP credentials per mailbox (not stored in CRDT for security) */ interface ImapConfig { imapUser: string | null; imapHost: string | null; imapPort: number | null; } const _imapConfigs = new Map(); // mailboxId → config /** IMAP sync state per mailbox (transient server state) */ interface ImapSyncState { mailboxId: string; lastUid: number; uidValidity: number | null; lastSyncAt: number | null; error: string | null; } const _syncStates = new Map(); // mailboxId → state // ── Helpers ── /** Default space used when no space param is provided */ const DEFAULT_SPACE = "global"; function generateId(): string { return crypto.randomUUID(); } /** * Ensure a mailbox Automerge doc exists for the given space + mailboxId. * Returns the current doc state. */ function ensureMailboxDoc(space: string, mailboxId: string): MailboxDoc { const docId = mailboxDocId(space, mailboxId); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init mailbox', (d) => { const init = mailboxSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.mailbox = init.mailbox; d.mailbox.id = mailboxId; d.members = []; d.threads = {}; d.approvals = {}; }); _syncServer!.setDoc(docId, doc); } return doc; } /** * Find a mailbox doc by slug across all docs on the SyncServer. * Returns [space, mailboxId, doc] or null. */ function findMailboxBySlug(slug: string): [string, string, MailboxDoc] | null { for (const id of _syncServer!.getDocIds()) { // Match pattern: {space}:inbox:mailboxes:{mailboxId} const parts = id.split(':'); if (parts.length === 4 && parts[1] === 'inbox' && parts[2] === 'mailboxes') { const doc = _syncServer!.getDoc(id); if (doc && doc.mailbox && doc.mailbox.slug === slug) { return [parts[0], parts[3], doc]; } } } return null; } /** * Find a mailbox doc by mailbox ID across all docs on the SyncServer. * Returns [space, docId, doc] or null. */ function findMailboxById(mailboxId: string): [string, string, MailboxDoc] | null { for (const id of _syncServer!.getDocIds()) { const parts = id.split(':'); if (parts.length === 4 && parts[1] === 'inbox' && parts[2] === 'mailboxes' && parts[3] === mailboxId) { const doc = _syncServer!.getDoc(id); if (doc) return [parts[0], id, doc]; } } return null; } /** * Get all mailbox docs from the SyncServer. */ function getAllMailboxDocs(): Array<{ space: string; docId: string; doc: MailboxDoc }> { const results: Array<{ space: string; docId: string; doc: MailboxDoc }> = []; for (const id of _syncServer!.getDocIds()) { const parts = id.split(':'); if (parts.length === 4 && parts[1] === 'inbox' && parts[2] === 'mailboxes') { const doc = _syncServer!.getDoc(id); if (doc) results.push({ space: parts[0], docId: id, doc }); } } return results; } /** * Find a thread by ID across all mailbox docs. * Returns [docId, threadId, thread, doc] or null. */ function findThreadById(threadId: string): [string, string, ThreadItem, MailboxDoc] | null { for (const { docId, doc } of getAllMailboxDocs()) { const thread = doc.threads[threadId]; if (thread) return [docId, threadId, thread, doc]; } return null; } /** * Find an approval by ID across all mailbox docs. * Returns [docId, approvalId, approval, doc] or null. */ function findApprovalById(approvalId: string): [string, string, ApprovalItem, MailboxDoc] | null { for (const { docId, doc } of getAllMailboxDocs()) { const approval = doc.approvals[approvalId]; if (approval) return [docId, approvalId, approval, doc]; } return null; } /** Convert MailboxMeta to REST response format (snake_case) */ function mailboxToRest(mb: MailboxMeta) { return { id: mb.id, workspace_id: mb.workspaceId, slug: mb.slug, name: mb.name, email: mb.email, description: mb.description, visibility: mb.visibility, owner_did: mb.ownerDid, safe_address: mb.safeAddress, safe_chain_id: mb.safeChainId, approval_threshold: mb.approvalThreshold, created_at: new Date(mb.createdAt).toISOString(), imap_user: _imapConfigs.get(mb.id)?.imapUser || null, }; } /** Convert ThreadItem to REST response format */ function threadToRest(t: ThreadItem) { return { id: t.id, mailbox_id: t.mailboxId, message_id: t.messageId, subject: t.subject, from_address: t.fromAddress, from_name: t.fromName, to_addresses: t.toAddresses, cc_addresses: t.ccAddresses, body_text: t.bodyText, body_html: t.bodyHtml, tags: t.tags, status: t.status, is_read: t.isRead, is_starred: t.isStarred, assigned_to: t.assignedTo, has_attachments: t.hasAttachments, received_at: new Date(t.receivedAt).toISOString(), created_at: new Date(t.createdAt).toISOString(), comment_count: t.comments.length, }; } /** Convert ThreadComment to REST response format */ function commentToRest(c: ThreadComment) { return { id: c.id, thread_id: c.threadId, author_id: c.authorId, author_did: c.authorId, // In Automerge, authorId IS the DID username: null as string | null, body: c.body, mentions: c.mentions, created_at: new Date(c.createdAt).toISOString(), }; } /** Convert ApprovalItem to REST response format */ function approvalToRest(a: ApprovalItem) { return { id: a.id, mailbox_id: a.mailboxId, thread_id: a.threadId, author_id: a.authorId, subject: a.subject, body_text: a.bodyText, body_html: a.bodyHtml, to_addresses: a.toAddresses, cc_addresses: a.ccAddresses, status: a.status, required_signatures: a.requiredSignatures, safe_tx_hash: a.safeTxHash, created_at: new Date(a.createdAt).toISOString(), resolved_at: a.resolvedAt ? new Date(a.resolvedAt).toISOString() : null, signature_count: a.signatures.length, }; } // ── Mailboxes API ── // GET /api/mailboxes — list mailboxes routes.get("/api/mailboxes", async (c) => { const { workspace } = c.req.query(); const allDocs = getAllMailboxDocs(); let mailboxes: ReturnType[]; if (workspace) { const ws = _workspaces.get(workspace); if (!ws) return c.json({ mailboxes: [] }); mailboxes = allDocs .filter(({ doc }) => doc.mailbox.workspaceId === ws.id) .map(({ doc }) => mailboxToRest(doc.mailbox)) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); } else { mailboxes = allDocs .map(({ doc }) => mailboxToRest(doc.mailbox)) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .slice(0, 50); } return c.json({ mailboxes }); }); // 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); // Check uniqueness const existing = findMailboxBySlug(slug); if (existing) return c.json({ error: "Mailbox already exists" }, 409); const space = (c.req.query("space") || c.req.param("space") || DEFAULT_SPACE); const mailboxId = generateId(); const now = Date.now(); // Create Automerge doc const doc = Automerge.change(Automerge.init(), 'Create mailbox', (d) => { d.meta = { module: 'inbox', collection: 'mailboxes', version: 1, spaceSlug: space, createdAt: now, }; d.mailbox = { id: mailboxId, workspaceId: null, slug, name, email, description: description || '', visibility, ownerDid: claims.sub, safeAddress: null, safeChainId: null, approvalThreshold: 1, createdAt: now, }; d.members = []; d.threads = {}; d.approvals = {}; }); _syncServer!.setDoc(mailboxDocId(space, mailboxId), doc); // Store IMAP config separately if (imap_user) { _imapConfigs.set(mailboxId, { imapUser: imap_user, imapHost: null, imapPort: null }); } return c.json(mailboxToRest(doc.mailbox), 201); }); // GET /api/mailboxes/:slug — mailbox detail routes.get("/api/mailboxes/:slug", async (c) => { const slug = c.req.param("slug"); const found = findMailboxBySlug(slug); if (!found) return c.json({ error: "Mailbox not found" }, 404); const [, , doc] = found; // Compute thread counts by status const threadCounts: Record = {}; for (const thread of Object.values(doc.threads)) { threadCounts[thread.status] = (threadCounts[thread.status] || 0) + 1; } return c.json({ ...mailboxToRest(doc.mailbox), 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 found = findMailboxBySlug(slug); if (!found) return c.json({ error: "Mailbox not found" }, 404); const [, , doc] = found; let threads = Object.values(doc.threads); // Filter by status if (status) { threads = threads.filter((t) => t.status === status); } // Filter by search (case-insensitive on subject and from_address) if (search) { const q = search.toLowerCase(); threads = threads.filter((t) => (t.subject && t.subject.toLowerCase().includes(q)) || (t.fromAddress && t.fromAddress.toLowerCase().includes(q)) ); } // Sort by receivedAt descending threads.sort((a, b) => b.receivedAt - a.receivedAt); // Paginate const lim = Math.min(parseInt(limit), 100); const off = parseInt(offset) || 0; const page = threads.slice(off, off + lim); return c.json({ threads: page.map(threadToRest) }); }); // GET /api/threads/:id — thread detail with comments routes.get("/api/threads/:id", async (c) => { const id = c.req.param("id"); const found = findThreadById(id); if (!found) return c.json({ error: "Thread not found" }, 404); const [, , thread] = found; const comments = [...thread.comments] .sort((a, b) => a.createdAt - b.createdAt) .map(commentToRest); return c.json({ ...threadToRest(thread), 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"] as const; // Check at least one valid field const hasUpdate = allowed.some((key) => key in body); if (!hasUpdate) return c.json({ error: "No valid fields" }, 400); const found = findThreadById(id); if (!found) return c.json({ error: "Thread not found" }, 404); const [docId] = found; const updated = _syncServer!.changeDoc(docId, `Update thread ${id}`, (d) => { const t = d.threads[id]; if (!t) return; if ("status" in body) t.status = body.status; if ("is_read" in body) t.isRead = body.is_read; if ("is_starred" in body) t.isStarred = body.is_starred; if ("tags" in body) { // Replace tags array t.tags.length = 0; for (const tag of body.tags) t.tags.push(tag); } if ("assigned_to" in body) t.assignedTo = body.assigned_to; }); if (!updated) return c.json({ error: "Thread not found" }, 404); const thread = updated.threads[id]; return c.json(threadToRest(thread)); }); // 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); const found = findThreadById(threadId); if (!found) return c.json({ error: "Thread not found" }, 404); const [docId] = found; const commentId = generateId(); const now = Date.now(); _syncServer!.changeDoc(docId, `Add comment to thread ${threadId}`, (d) => { const t = d.threads[threadId]; if (!t) return; t.comments.push({ id: commentId, threadId, authorId: claims.sub, body: text, mentions: mentions || [], createdAt: now, }); }); const comment: ThreadComment = { id: commentId, threadId, authorId: claims.sub, body: text, mentions: mentions || [], createdAt: now, }; return c.json(commentToRest(comment), 201); }); // ── Approvals API ── // GET /api/approvals — list pending approvals routes.get("/api/approvals", async (c) => { const { mailbox, status = "PENDING" } = c.req.query(); if (mailbox) { const found = findMailboxBySlug(mailbox); if (!found) return c.json({ error: "Mailbox not found" }, 404); const [, , doc] = found; const approvals = Object.values(doc.approvals) .filter((a) => a.status === status) .sort((a, b) => b.createdAt - a.createdAt) .map(approvalToRest); return c.json({ approvals }); } else { const allApprovals: ReturnType[] = []; for (const { doc } of getAllMailboxDocs()) { for (const a of Object.values(doc.approvals)) { if (a.status === status) allApprovals.push(approvalToRest(a)); } } allApprovals.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); return c.json({ approvals: allApprovals.slice(0, 50) }); } }); // 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 found = findMailboxBySlug(mailbox_slug); if (!found) return c.json({ error: "Mailbox not found" }, 404); const [space, mailboxId, doc] = found; const docId = mailboxDocId(space, mailboxId); const approvalId = generateId(); const now = Date.now(); _syncServer!.changeDoc(docId, `Create approval draft`, (d) => { d.approvals[approvalId] = { id: approvalId, mailboxId: doc.mailbox.id, threadId: thread_id || null, authorId: claims.sub, subject, bodyText: body_text || '', bodyHtml: '', toAddresses: to_addresses || [], ccAddresses: [], status: 'PENDING', requiredSignatures: doc.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: now, resolvedAt: 0, signatures: [], }; }); const approval: ApprovalItem = { id: approvalId, mailboxId: doc.mailbox.id, threadId: thread_id || null, authorId: claims.sub, subject, bodyText: body_text || '', bodyHtml: '', toAddresses: to_addresses || [], ccAddresses: [], status: 'PENDING', requiredSignatures: doc.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: now, resolvedAt: 0, signatures: [], }; return c.json(approvalToRest(approval), 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 found = findApprovalById(id); if (!found) return c.json({ error: "Approval not found" }, 404); const [docId, , approval] = found; if (approval.status !== "PENDING") return c.json({ error: "Approval not pending" }, 400); const now = Date.now(); const updated = _syncServer!.changeDoc(docId, `Sign approval ${id}`, (d) => { const a = d.approvals[id]; if (!a) return; // Upsert signature: replace if signer already voted const existingIdx = a.signatures.findIndex((s) => s.signerId === claims.sub); const sig: ApprovalSignature = { id: existingIdx >= 0 ? a.signatures[existingIdx].id : generateId(), approvalId: id, signerId: claims.sub, vote, signedAt: now, }; if (existingIdx >= 0) { a.signatures[existingIdx].vote = vote; a.signatures[existingIdx].signedAt = now; } else { a.signatures.push(sig); } // Count approvals and rejections const approveCount = a.signatures.filter((s) => s.vote === 'APPROVE').length; const rejectCount = a.signatures.filter((s) => s.vote === 'REJECT').length; if (approveCount >= a.requiredSignatures) { a.status = 'APPROVED'; a.resolvedAt = now; } else if (rejectCount > 0) { a.status = 'REJECTED'; a.resolvedAt = now; } }); if (!updated) return c.json({ error: "Approval not found" }, 404); const finalApproval = updated.approvals[id]; const approveCount = finalApproval.signatures.filter((s) => s.vote === 'APPROVE').length; return c.json({ ok: true, status: finalApproval.status, signatures: approveCount, ...(finalApproval.status === 'PENDING' ? { required: finalApproval.requiredSignatures } : {}), }); }); // ── Workspaces API ── // GET /api/workspaces routes.get("/api/workspaces", async (c) => { const workspaces = Array.from(_workspaces.values()) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 50) .map((ws) => ({ id: ws.id, slug: ws.slug, name: ws.name, description: ws.description, owner_did: ws.ownerDid, created_at: new Date(ws.createdAt).toISOString(), })); return c.json({ workspaces }); }); // 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); if (_workspaces.has(slug)) return c.json({ error: "Workspace already exists" }, 409); const ws: WorkspaceInfo = { id: generateId(), slug, name, description: description || null, ownerDid: claims.sub, createdAt: Date.now(), }; _workspaces.set(slug, ws); return c.json({ id: ws.id, slug: ws.slug, name: ws.name, description: ws.description, owner_did: ws.ownerDid, created_at: new Date(ws.createdAt).toISOString(), }, 201); }); // 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 syncStates: any[] = []; for (const [mailboxId, state] of _syncStates) { const found = findMailboxById(mailboxId); if (!found) continue; const [, , doc] = found; syncStates.push({ mailbox_id: mailboxId, last_uid: state.lastUid, uid_validity: state.uidValidity, last_sync_at: state.lastSyncAt ? new Date(state.lastSyncAt).toISOString() : null, error: state.error, slug: doc.mailbox.slug, name: doc.mailbox.name, email: doc.mailbox.email, }); } syncStates.sort((a, b) => { if (!a.last_sync_at && !b.last_sync_at) return 0; if (!a.last_sync_at) return 1; if (!b.last_sync_at) return -1; return new Date(b.last_sync_at).getTime() - new Date(a.last_sync_at).getTime(); }); return c.json({ syncStates }); }); // ── 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 interface SyncableMailbox { id: string; slug: string; email: string; space: string; imap_user: string | null; imap_host: string | null; imap_port: number | null; } async function syncMailbox(mailbox: SyncableMailbox) { 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 = _syncStates.get(mailbox.id); if (!syncState) { syncState = { mailboxId: mailbox.id, lastUid: 0, uidValidity: null, lastSyncAt: null, error: null, }; _syncStates.set(mailbox.id, syncState); } 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.uidValidity && uidValidity && syncState.uidValidity !== uidValidity) { syncState.lastUid = 0; syncState.uidValidity = uidValidity; } // Fetch messages newer than last synced UID const lastUid = syncState.lastUid || 0; const range = lastUid > 0 ? `${lastUid + 1}:*` : "1:*"; let maxUid = lastUid; let count = 0; const docId = mailboxDocId(mailbox.space, mailbox.id); 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) => a.address || ""); const ccAddrs = (parsed.cc?.value || []).map((a: any) => a.address || ""); const messageId = parsed.messageId || msg.envelope?.messageId || null; const hasAttachments = (parsed.attachments?.length || 0) > 0; const receivedAt = parsed.date ? parsed.date.getTime() : Date.now(); const threadId = generateId(); // Check for duplicate messageId before inserting const currentDoc = _syncServer!.getDoc(docId); const isDuplicate = messageId && currentDoc && Object.values(currentDoc.threads).some((t) => t.messageId === messageId); if (!isDuplicate) { _syncServer!.changeDoc(docId, `IMAP sync: ${subject}`, (d) => { d.threads[threadId] = { id: threadId, mailboxId: mailbox.id, messageId, subject, fromAddress: fromAddr, fromName: fromName, toAddresses: toAddrs, ccAddresses: ccAddrs, bodyText: parsed.text || '', bodyHtml: parsed.html || '', tags: [], status: 'open', isRead: false, isStarred: false, assignedTo: null, hasAttachments, receivedAt, createdAt: Date.now(), comments: [], }; }); count++; } } catch (parseErr) { console.error(`[Inbox] Parse error UID ${msg.uid}:`, parseErr); } if (msg.uid > maxUid) maxUid = msg.uid; } // Update sync state syncState.lastUid = maxUid; syncState.uidValidity = uidValidity || null; syncState.lastSyncAt = Date.now(); syncState.error = null; 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); if (syncState) { syncState.error = e.message; syncState.lastSyncAt = Date.now(); } } } 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 () => { if (!_syncServer) return; try { // Find all mailboxes with IMAP config const mailboxes: SyncableMailbox[] = []; for (const { space, doc } of getAllMailboxDocs()) { const imapCfg = _imapConfigs.get(doc.mailbox.id); if (imapCfg?.imapUser) { mailboxes.push({ id: doc.mailbox.id, slug: doc.mailbox.slug, email: doc.mailbox.email, space, imap_user: imapCfg.imapUser, imap_host: imapCfg.imapHost, imap_port: imapCfg.imapPort, }); } } 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(); // ── About / use-cases landing ── routes.get("/about", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `Multi-Sig Inbox — rInbox | rSpace`, moduleId: "rinbox", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: `

What can you do with a multi-sig inbox?

When outbound email requires collective approval, entirely new coordination patterns become possible.

Governance & Resolutions

Board decisions require 3-of-5 signers before the email sends. The message is the vote — no separate tooling needed.

Escrow & Conditional Release

Hold sensitive documents in an inbox that only unlocks when N parties agree. Mediation where neither side can act alone.

Whistleblower Coordination

Evidence requires M-of-N co-signers before release. Dead man’s switch if a signer goes silent. Nobody goes first alone.

Social Key Recovery

Lost access? 3-of-5 trusted contacts co-sign your restoration. No phone number, no backup email — a trust network.

Tamper-Proof Audit Trails

Every email read and sent is co-signed. Cryptographic proof of who approved what, when. Built for compliance.

Treasury & Payments

Invoice arrives, 2-of-3 finance team co-sign the reply authorizing payment. Bridges email to on-chain wallets.

`, scripts: "", styles: "", })); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Inbox | rSpace`, moduleId: "rinbox", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); // ── Seed template data ── function seedTemplateInbox(space: string) { if (!_syncServer) return; // Skip if space already has mailboxes const prefix = `${space}:inbox:mailboxes:`; const existing = _syncServer.listDocs().filter((id) => id.startsWith(prefix)); if (existing.length > 0) return; const mbId = crypto.randomUUID(); const docId = mailboxDocId(space, mbId); const now = Date.now(); const day = 86400000; const doc = Automerge.change(Automerge.init(), 'seed template mailbox', (d) => { d.meta = { module: 'inbox', collection: 'mailboxes', version: 1, spaceSlug: space, createdAt: now }; d.mailbox = { id: mbId, workspaceId: null, slug: 'commons-team', name: 'Commons Team', email: `commons-team@${space}.rspace.online`, description: 'Shared mailbox for the commons coordination team.', visibility: 'members', ownerDid: 'did:demo:seed', safeAddress: null, safeChainId: null, approvalThreshold: 1, createdAt: now, }; d.members = []; d.threads = {}; d.approvals = {}; const threads: Array<{ subj: string; from: string; fromName: string; body: string; tags: string[]; age: number }> = [ { subj: 'Grant Application Update — Gitcoin GG22', from: 'grants@gitcoin.co', fromName: 'Gitcoin Grants', body: 'Your application for the Cosmolocal Commons project has been accepted into GG22 Climate Solutions round. Matching pool opens April 1.', tags: ['grants', 'important'], age: 2, }, { subj: 'Partnership Inquiry — Barcelona Maker Space', from: 'hello@bcnmakers.cat', fromName: 'BCN Makers', body: 'Hi! We saw your cosmolocal print network and would love to join as a provider. We have risograph, screen print, and laser cutting capabilities.', tags: ['partnerships'], age: 5, }, { subj: 'Weekly Digest — rSpace Dev Updates', from: 'digest@rspace.online', fromName: 'rSpace Bot', body: '## This Week\n\n- 3 new modules deployed (rChoices, rSplat, rInbox)\n- EncryptID guardian recovery shipped\n- 2 new providers joined the cosmolocal network', tags: ['digest'], age: 1, }, ]; for (const t of threads) { const tId = crypto.randomUUID(); d.threads[tId] = { id: tId, mailboxId: mbId, messageId: `<${crypto.randomUUID()}@demo>`, subject: t.subj, fromAddress: t.from, fromName: t.fromName, toAddresses: [`commons-team@${space}.rspace.online`], ccAddresses: [], bodyText: t.body, bodyHtml: '', tags: t.tags, status: 'open', isRead: t.age > 3, isStarred: t.tags.includes('important'), assignedTo: null, hasAttachments: false, receivedAt: now - t.age * day, createdAt: now - t.age * day, comments: [], }; } }); _syncServer.setDoc(docId, doc); console.log(`[Inbox] Template seeded for "${space}": 1 mailbox, 3 threads`); } export const inboxModule: RSpaceModule = { id: "rinbox", name: "rInbox", icon: "\u{1F4E8}", description: "Collaborative email with multisig approval", scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [{ pattern: '{space}:inbox:mailboxes:{mailboxId}', description: 'Mailbox with threads and approvals', init: mailboxSchema.init }], routes, landingPage: renderLanding, seedTemplate: seedTemplateInbox, async onInit(ctx) { _syncServer = ctx.syncServer; console.log("[Inbox] Module initialized (Automerge-only, no PG)"); }, standaloneDomain: "rinbox.online", feeds: [ { id: "messages", name: "Messages", kind: "data", description: "Email threads and messages across shared mailboxes", filterable: true, }, { id: "notifications", name: "Notifications", kind: "attention", description: "New mail alerts, approval requests, and mention notifications", }, ], acceptsFeeds: ["data"], };