/** * 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, type PersonalInbox, type AgentInbox, type AgentRule, } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── SMTP Transport (lazy singleton) ── let _smtpTransport: any = null; const SMTP_HOST = process.env.SMTP_HOST || "mail.rmail.online"; const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587"); const SMTP_USER = process.env.SMTP_USER || ""; const SMTP_PASS = process.env.SMTP_PASS || ""; async function getSmtpTransport() { if (_smtpTransport) return _smtpTransport; try { const nodemailer = await import("nodemailer"); const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport; _smtpTransport = createTransport({ host: SMTP_HOST, port: SMTP_PORT, secure: SMTP_PORT === 465, auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, }); console.log(`[Inbox] SMTP transport configured: ${SMTP_HOST}:${SMTP_PORT}`); return _smtpTransport; } catch (e) { console.error("[Inbox] Failed to create SMTP transport:", e); return null; } } // ── 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 /** Personal inbox IMAP/SMTP credentials (not stored in CRDT for security) */ interface PersonalImapSmtpConfig { imapUser: string; imapPass: string; smtpUser: string; smtpPass: string; } const _personalCredentials = new Map(); // personalInboxId → creds /** 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 = {}; d.personalInboxes = {}; d.agentInboxes = {}; }); _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, in_reply_to: t.inReplyTo || null, references: t.references || [], direction: t.direction || 'inbound', parent_thread_id: t.parentThreadId || null, }; } /** 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, in_reply_to: a.inReplyTo || null, references: a.references || [], reply_type: a.replyType || 'new', }; } /** Build a quoted message block for replies */ function quoteBlock(thread: ThreadItem): string { const date = new Date(thread.receivedAt).toLocaleString(); const from = thread.fromName ? `${thread.fromName} <${thread.fromAddress}>` : (thread.fromAddress || 'Unknown'); const quotedBody = (thread.bodyText || '') .split('\n') .map(line => `> ${line}`) .join('\n'); return `\nOn ${date}, ${from} wrote:\n${quotedBody}\n`; } /** Build forwarded message block */ function forwardBlock(thread: ThreadItem): string { const date = new Date(thread.receivedAt).toLocaleString(); return [ '', '---------- Forwarded message ----------', `From: ${thread.fromName || ''} <${thread.fromAddress || ''}>`, `Date: ${date}`, `Subject: ${thread.subject}`, `To: ${(thread.toAddresses || []).join(', ')}`, '', thread.bodyText || '', ].join('\n'); } // ── Execute Approved Email ── async function executeApproval(docId: string, approvalId: string) { const transport = await getSmtpTransport(); if (!transport) { console.error(`[Inbox] No SMTP transport — cannot send approval ${approvalId}`); return; } const doc = _syncServer!.getDoc(docId); if (!doc) return; const approval = doc.approvals[approvalId]; if (!approval || approval.status !== 'APPROVED') return; // Find the mailbox to get the from address const mailboxEmail = doc.mailbox.email; try { const mailOptions: any = { from: mailboxEmail, to: approval.toAddresses.join(', '), subject: approval.subject, text: approval.bodyText, }; if (approval.ccAddresses.length > 0) { mailOptions.cc = approval.ccAddresses.join(', '); } if (approval.bodyHtml) { mailOptions.html = approval.bodyHtml; } if (approval.inReplyTo) { mailOptions.inReplyTo = approval.inReplyTo; } if (approval.references && approval.references.length > 0) { mailOptions.references = approval.references.join(' '); } await transport.sendMail(mailOptions); // Update status to SENT and create outbound thread _syncServer!.changeDoc(docId, `Send approval ${approvalId}`, (d) => { const a = d.approvals[approvalId]; if (!a || a.status !== 'APPROVED') return; // Guard double-send a.status = 'SENT'; // Create outbound thread record const threadId = generateId(); d.threads[threadId] = { id: threadId, mailboxId: d.mailbox.id, messageId: null, subject: a.subject, fromAddress: mailboxEmail, fromName: d.mailbox.name, toAddresses: [...a.toAddresses], ccAddresses: [...a.ccAddresses], bodyText: a.bodyText, bodyHtml: a.bodyHtml, tags: ['sent'], status: 'closed', isRead: true, isStarred: false, assignedTo: null, hasAttachments: false, receivedAt: Date.now(), createdAt: Date.now(), comments: [], inReplyTo: a.inReplyTo || null, references: [...(a.references || [])], direction: 'outbound', parentThreadId: a.threadId || null, }; }); console.log(`[Inbox] Sent approval ${approvalId}: "${approval.subject}" → ${approval.toAddresses.join(', ')}`); } catch (e: any) { console.error(`[Inbox] Failed to send approval ${approvalId}:`, e.message); // Mark as error but don't reset to PENDING _syncServer!.changeDoc(docId, `Send error for approval ${approvalId}`, (d) => { const a = d.approvals[approvalId]; if (a) a.status = 'SEND_ERROR'; }); } } // ── 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 = "private", 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: 2, 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 = {}; d.personalInboxes = {}; d.agentInboxes = {}; }); _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); }); // ── Reply / Forward API ── // POST /api/threads/:id/reply — create reply approval routes.post("/api/threads/:id/reply", 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 found = findThreadById(threadId); if (!found) return c.json({ error: "Thread not found" }, 404); const [docId, , thread, doc] = found; const body = await c.req.json(); const approvalId = generateId(); const now = Date.now(); const subject = thread.subject.startsWith('Re:') ? thread.subject : `Re: ${thread.subject}`; const toAddresses = thread.fromAddress ? [thread.fromAddress] : []; const replyBody = (body.body_text || '') + quoteBlock(thread); const references = [...(thread.references || [])]; if (thread.messageId) references.push(thread.messageId); _syncServer!.changeDoc(docId, `Reply to thread ${threadId}`, (d) => { d.approvals[approvalId] = { id: approvalId, mailboxId: doc.mailbox.id, threadId, authorId: claims.sub, subject, bodyText: replyBody, bodyHtml: '', toAddresses, ccAddresses: [], status: 'PENDING', requiredSignatures: doc.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: now, resolvedAt: 0, signatures: [], inReplyTo: thread.messageId || null, references, replyType: 'reply', }; }); const updatedDoc = _syncServer!.getDoc(docId)!; return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201); }); // POST /api/threads/:id/reply-all — reply to all recipients routes.post("/api/threads/:id/reply-all", 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 found = findThreadById(threadId); if (!found) return c.json({ error: "Thread not found" }, 404); const [docId, , thread, doc] = found; const body = await c.req.json(); const approvalId = generateId(); const now = Date.now(); const subject = thread.subject.startsWith('Re:') ? thread.subject : `Re: ${thread.subject}`; const mailboxEmail = doc.mailbox.email.toLowerCase(); // To: original sender + original To (minus self) const allRecipients = new Set(); if (thread.fromAddress) allRecipients.add(thread.fromAddress); for (const addr of thread.toAddresses || []) { if (addr.toLowerCase() !== mailboxEmail) allRecipients.add(addr); } const toAddresses = Array.from(allRecipients); // CC: original CC (minus self) const ccAddresses = (thread.ccAddresses || []).filter(a => a.toLowerCase() !== mailboxEmail); const replyBody = (body.body_text || '') + quoteBlock(thread); const references = [...(thread.references || [])]; if (thread.messageId) references.push(thread.messageId); _syncServer!.changeDoc(docId, `Reply-all to thread ${threadId}`, (d) => { d.approvals[approvalId] = { id: approvalId, mailboxId: doc.mailbox.id, threadId, authorId: claims.sub, subject, bodyText: replyBody, bodyHtml: '', toAddresses, ccAddresses, status: 'PENDING', requiredSignatures: doc.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: now, resolvedAt: 0, signatures: [], inReplyTo: thread.messageId || null, references, replyType: 'reply-all', }; }); const updatedDoc = _syncServer!.getDoc(docId)!; return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201); }); // POST /api/threads/:id/forward — forward a thread routes.post("/api/threads/:id/forward", 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 found = findThreadById(threadId); if (!found) return c.json({ error: "Thread not found" }, 404); const [docId, , thread, doc] = found; const body = await c.req.json(); const { to_addresses } = body; if (!to_addresses || !Array.isArray(to_addresses) || to_addresses.length === 0) { return c.json({ error: "to_addresses required" }, 400); } const approvalId = generateId(); const now = Date.now(); const subject = thread.subject.startsWith('Fwd:') ? thread.subject : `Fwd: ${thread.subject}`; const fwdBody = (body.body_text || '') + forwardBlock(thread); _syncServer!.changeDoc(docId, `Forward thread ${threadId}`, (d) => { d.approvals[approvalId] = { id: approvalId, mailboxId: doc.mailbox.id, threadId, authorId: claims.sub, subject, bodyText: fwdBody, bodyHtml: '', toAddresses: to_addresses, ccAddresses: [], status: 'PENDING', requiredSignatures: doc.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: now, resolvedAt: 0, signatures: [], inReplyTo: null, // Forwards don't thread references: [], replyType: 'forward', }; }); const updatedDoc = _syncServer!.getDoc(docId)!; return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 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) }); } }); // GET /api/approvals/:id — get single approval detail routes.get("/api/approvals/:id", async (c) => { const id = c.req.param("id"); const found = findApprovalById(id); if (!found) return c.json({ error: "Approval not found" }, 404); const [, , approval] = found; return c.json({ ...approvalToRest(approval), signatures: approval.signatures.map(s => ({ id: s.id, signer_id: s.signerId, vote: s.vote, signed_at: new Date(s.signedAt).toISOString(), })), }); }); // 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, cc_addresses, in_reply_to, references: refs, reply_type } = 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: cc_addresses || [], status: 'PENDING', requiredSignatures: doc.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: now, resolvedAt: 0, signatures: [], inReplyTo: in_reply_to || null, references: refs || [], replyType: reply_type || 'new', }; }); const updatedDoc = _syncServer!.getDoc(docId)!; return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 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; // If approved, fire async send if (finalApproval.status === 'APPROVED') { executeApproval(docId, id).catch(e => console.error('[Inbox] executeApproval error:', e)); } return c.json({ ok: true, status: finalApproval.status, signatures: approveCount, ...(finalApproval.status === 'PENDING' ? { required: finalApproval.requiredSignatures } : {}), }); }); // ── Personal Inboxes API ── // POST /api/personal-inboxes — connect a personal email account routes.post("/api/personal-inboxes", 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 { label, email, imap_host, imap_port, imap_user, imap_pass, smtp_host, smtp_port, smtp_user, smtp_pass } = body; if (!label || !email || !imap_host || !imap_user || !imap_pass) { return c.json({ error: "label, email, imap_host, imap_user, imap_pass required" }, 400); } // Validate IMAP connection try { const { ImapFlow } = await import("imapflow"); const client = new ImapFlow({ host: imap_host, port: imap_port || 993, secure: (imap_port || 993) === 993, auth: { user: imap_user, pass: imap_pass }, logger: false, }); await client.connect(); await client.logout(); } catch (e: any) { return c.json({ error: `IMAP connection failed: ${e.message}` }, 400); } const inboxId = generateId(); const now = Date.now(); // Store credentials securely (not in Automerge) _personalCredentials.set(inboxId, { imapUser: imap_user, imapPass: imap_pass, smtpUser: smtp_user || imap_user, smtpPass: smtp_pass || imap_pass, }); // Store metadata in first available mailbox doc for this space const space = c.req.query("space") || c.req.param("space") || DEFAULT_SPACE; const allDocs = getAllMailboxDocs().filter(d => d.space === space); if (allDocs.length === 0) return c.json({ error: "No mailbox in this space" }, 404); const { docId } = allDocs[0]; _syncServer!.changeDoc(docId, `Add personal inbox ${inboxId}`, (d) => { if (!d.personalInboxes) d.personalInboxes = {}; d.personalInboxes[inboxId] = { id: inboxId, ownerDid: claims.sub, label, email, imapHost: imap_host, imapPort: imap_port || 993, smtpHost: smtp_host || imap_host, smtpPort: smtp_port || 587, lastSyncAt: 0, status: 'active', }; }); return c.json({ id: inboxId, label, email, imap_host, imap_port: imap_port || 993, smtp_host: smtp_host || imap_host, smtp_port: smtp_port || 587, status: 'active', last_sync_at: null, }, 201); }); // GET /api/personal-inboxes — list personal inboxes for authenticated user routes.get("/api/personal-inboxes", 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 inboxes: any[] = []; for (const { doc } of getAllMailboxDocs()) { if (!doc.personalInboxes) continue; for (const pi of Object.values(doc.personalInboxes)) { if (pi.ownerDid === claims.sub) { inboxes.push({ id: pi.id, label: pi.label, email: pi.email, imap_host: pi.imapHost, imap_port: pi.imapPort, smtp_host: pi.smtpHost, smtp_port: pi.smtpPort, status: pi.status, last_sync_at: pi.lastSyncAt ? new Date(pi.lastSyncAt).toISOString() : null, }); } } } return c.json({ personal_inboxes: inboxes }); }); // DELETE /api/personal-inboxes/:id — disconnect a personal inbox routes.delete("/api/personal-inboxes/:id", 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 inboxId = c.req.param("id"); _personalCredentials.delete(inboxId); for (const { docId, doc } of getAllMailboxDocs()) { if (doc.personalInboxes?.[inboxId] && doc.personalInboxes[inboxId].ownerDid === claims.sub) { _syncServer!.changeDoc(docId, `Remove personal inbox ${inboxId}`, (d) => { if (d.personalInboxes?.[inboxId]) { delete d.personalInboxes[inboxId]; } }); return c.json({ ok: true }); } } return c.json({ error: "Personal inbox not found" }, 404); }); // ── Agent Inboxes API ── // POST /api/agent-inboxes — create an agent inbox routes.post("/api/agent-inboxes", 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 { name, email, personality, auto_reply = false, auto_classify = false, rules = [] } = body; if (!name || !email) return c.json({ error: "name and email required" }, 400); const space = c.req.query("space") || c.req.param("space") || DEFAULT_SPACE; const allDocs = getAllMailboxDocs().filter(d => d.space === space); if (allDocs.length === 0) return c.json({ error: "No mailbox in this space" }, 404); const agentId = generateId(); const { docId } = allDocs[0]; _syncServer!.changeDoc(docId, `Create agent inbox ${agentId}`, (d) => { if (!d.agentInboxes) d.agentInboxes = {}; d.agentInboxes[agentId] = { id: agentId, spaceSlug: space, name, email, personality: personality || '', autoReply: auto_reply, autoClassify: auto_classify, rules: rules.map((r: any) => ({ match: { field: r.match?.field || 'subject', pattern: r.match?.pattern || '' }, action: r.action || 'tag', value: r.value || undefined, })), }; }); return c.json({ id: agentId, name, email, personality: personality || '', auto_reply: auto_reply, auto_classify: auto_classify, rules, space_slug: space, }, 201); }); // GET /api/agent-inboxes — list agent inboxes for space routes.get("/api/agent-inboxes", async (c) => { const space = c.req.query("space") || c.req.param("space") || DEFAULT_SPACE; const agents: any[] = []; for (const { doc } of getAllMailboxDocs()) { if (!doc.agentInboxes) continue; for (const ai of Object.values(doc.agentInboxes)) { if (ai.spaceSlug === space || space === DEFAULT_SPACE) { agents.push({ id: ai.id, name: ai.name, email: ai.email, personality: ai.personality, auto_reply: ai.autoReply, auto_classify: ai.autoClassify, rules: ai.rules, space_slug: ai.spaceSlug, }); } } } return c.json({ agent_inboxes: agents }); }); // DELETE /api/agent-inboxes/:id — remove an agent inbox routes.delete("/api/agent-inboxes/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const agentId = c.req.param("id"); for (const { docId, doc } of getAllMailboxDocs()) { if (doc.agentInboxes?.[agentId]) { _syncServer!.changeDoc(docId, `Remove agent inbox ${agentId}`, (d) => { if (d.agentInboxes?.[agentId]) { delete d.agentInboxes[agentId]; } }); return c.json({ ok: true }); } } return c.json({ error: "Agent inbox not found" }, 404); }); // ── 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 !== "", smtpReady: !!SMTP_USER })); // 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 inReplyTo = parsed.inReplyTo || null; const references = parsed.references ? (Array.isArray(parsed.references) ? parsed.references : [parsed.references]) : []; 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: [], inReplyTo: inReplyTo || null, references, direction: 'inbound', parentThreadId: null, }; }); count++; // Agent inbox processing processAgentRules(docId, threadId); } } 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(); } } } /** Process agent inbox rules for a new inbound thread */ function processAgentRules(docId: string, threadId: string) { const doc = _syncServer!.getDoc(docId); if (!doc || !doc.agentInboxes) return; const thread = doc.threads[threadId]; if (!thread) return; for (const agent of Object.values(doc.agentInboxes)) { for (const rule of agent.rules) { let fieldValue = ''; switch (rule.match.field) { case 'from': fieldValue = thread.fromAddress || ''; break; case 'subject': fieldValue = thread.subject || ''; break; case 'body': fieldValue = thread.bodyText || ''; break; } try { if (new RegExp(rule.match.pattern, 'i').test(fieldValue)) { if (agent.autoClassify && rule.action === 'tag' && rule.value) { _syncServer!.changeDoc(docId, `Agent classify: tag ${rule.value}`, (d) => { const t = d.threads[threadId]; if (t && !t.tags.includes(rule.value!)) { t.tags.push(rule.value!); } }); } if (agent.autoClassify && rule.action === 'assign' && rule.value) { _syncServer!.changeDoc(docId, `Agent classify: assign ${rule.value}`, (d) => { const t = d.threads[threadId]; if (t) t.assignedTo = rule.value!; }); } if (agent.autoReply && rule.action === 'reply') { // Agent auto-replies go through approval workflow const approvalId = generateId(); const replySubject = thread.subject.startsWith('Re:') ? thread.subject : `Re: ${thread.subject}`; const references = [...(thread.references || [])]; if (thread.messageId) references.push(thread.messageId); _syncServer!.changeDoc(docId, `Agent auto-reply draft`, (d) => { d.approvals[approvalId] = { id: approvalId, mailboxId: d.mailbox.id, threadId, authorId: `agent:${agent.id}`, subject: replySubject, bodyText: rule.value || `[Auto-reply from ${agent.name}]`, bodyHtml: '', toAddresses: thread.fromAddress ? [thread.fromAddress] : [], ccAddresses: [], status: 'PENDING', requiredSignatures: d.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: Date.now(), resolvedAt: 0, signatures: [], inReplyTo: thread.messageId || null, references, replyType: 'reply', }; }); console.log(`[Inbox] Agent "${agent.name}" drafted auto-reply for thread ${threadId}`); } } } catch { /* invalid regex — skip */ } } } } 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: 2, spaceSlug: space, createdAt: now }; d.mailbox = { id: mbId, workspaceId: null, slug: `${space}-inbox`, name: `${space.charAt(0).toUpperCase() + space.slice(1)} Inbox`, email: `${space}@rspace.online`, description: `Shared mailbox for the ${space} space.`, visibility: 'members', ownerDid: 'did:demo:seed', safeAddress: null, safeChainId: null, approvalThreshold: 1, createdAt: now, }; d.members = []; d.threads = {}; d.approvals = {}; d.personalInboxes = {}; d.agentInboxes = {}; 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: [`${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: [], inReplyTo: null, references: [], direction: 'inbound', parentThreadId: null, }; } }); _syncServer.setDoc(docId, doc); console.log(`[Inbox] Template seeded for "${space}": 1 mailbox (${space}-inbox), 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)"); // Pre-warm SMTP transport if (SMTP_USER) getSmtpTransport().catch(() => {}); }, async onSpaceCreate(ctx) { seedTemplateInbox(ctx.spaceSlug); }, 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"], subPageInfos: [ { path: "about", title: "About rInbox", icon: "📮", tagline: "rInbox", description: "Collaborative email for communities — shared mailboxes with multisig approval, threaded discussions, and team workflows.", features: [ { icon: "📬", title: "Shared Mailboxes", text: "Create shared inboxes that multiple team members can read and respond from." }, { icon: "✅", title: "Multisig Approval", text: "Require multiple approvals before sending sensitive emails on behalf of the group." }, { icon: "💬", title: "Internal Comments", text: "Discuss emails privately with your team before crafting a response." }, ], }, ], };