/** * folk-inbox-client — Collaborative email client. * * Shows mailbox list, thread inbox, thread detail with comments, * and approval workflow interface. Includes a help/guide popout. */ class FolkInboxClient extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes"; private mailboxes: any[] = []; private threads: any[] = []; private currentMailbox: any = null; private currentThread: any = null; private approvals: any[] = []; private filter: "all" | "open" | "snoozed" | "closed" = "all"; private helpOpen = false; private composeOpen = false; private demoApprovals: any[] = [ { id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING", to_addresses: ["finance@example.org"], required_signatures: 3, signature_count: 1, body_text: "Approved. Please proceed with the cloud infrastructure upgrade as outlined. Budget: $4,200/quarter.", created_at: new Date(Date.now() - 4 * 3600000).toISOString(), signers: [ { name: "Alice Chen", vote: "APPROVE", signed_at: new Date(Date.now() - 3 * 3600000).toISOString() }, { name: "Bob Martinez", vote: null, signed_at: null }, { name: "Carol Wu", vote: null, signed_at: null }, ], }, { id: "a2", subject: "Partnership agreement with Acme Corp", status: "PENDING", to_addresses: ["legal@acmecorp.com", "partnerships@example.org"], required_signatures: 2, signature_count: 1, body_text: "We've reviewed the terms and are ready to proceed. Please find the signed MOU attached.", created_at: new Date(Date.now() - 12 * 3600000).toISOString(), signers: [ { name: "Dave Park", vote: "APPROVE", signed_at: new Date(Date.now() - 10 * 3600000).toISOString() }, { name: "Alice Chen", vote: null, signed_at: null }, ], }, { id: "a3", subject: "Re: Security incident disclosure", status: "APPROVED", to_addresses: ["security@example.com"], required_signatures: 2, signature_count: 2, body_text: "Disclosure notice prepared and co-signed. Sending to affected parties.", created_at: new Date(Date.now() - 48 * 3600000).toISOString(), signers: [ { name: "Bob Martinez", vote: "APPROVE", signed_at: new Date(Date.now() - 46 * 3600000).toISOString() }, { name: "Carol Wu", vote: "APPROVE", signed_at: new Date(Date.now() - 44 * 3600000).toISOString() }, ], }, ]; private demoThreads: Record = { team: [ { id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] }, { id: "t2", from_name: "Bob Martinez", from_address: "bob@example.com", subject: "Deploy checklist for v2.1", status: "open", is_read: false, is_starred: false, comment_count: 1, received_at: new Date(Date.now() - 5 * 3600000).toISOString(), body_text: "Here is the deploy checklist for v2.1. Please review before we cut the release.\n\n- [ ] Run full test suite\n- [ ] Update changelog\n- [ ] Tag release in Gitea\n- [ ] Deploy to staging\n- [ ] Smoke test all modules", comments: [{ username: "Alice Chen", body: "I can handle the changelog update.", created_at: new Date(Date.now() - 4 * 3600000).toISOString() }] }, { id: "t3", from_name: "Carol Wu", from_address: "carol@example.com", subject: "Design system color tokens", status: "snoozed", is_read: true, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 24 * 3600000).toISOString(), body_text: "I've been working on standardizing our color tokens across all modules. The current approach of inline hex values is getting unwieldy. Proposal attached.", comments: [] }, { id: "t4", from_name: "Dave Park", from_address: "dave@example.com", subject: "Q1 Retrospective summary", status: "closed", is_read: true, is_starred: false, comment_count: 5, received_at: new Date(Date.now() - 72 * 3600000).toISOString(), body_text: "Summary of our Q1 retrospective:\n\nWhat went well: Local-first architecture, community engagement, rapid prototyping.\nWhat to improve: Documentation, test coverage, onboarding flow.", comments: [{ username: "Alice Chen", body: "Great summary, Dave.", created_at: new Date(Date.now() - 70 * 3600000).toISOString() }, { username: "Bob Martinez", body: "+1 on improving docs.", created_at: new Date(Date.now() - 69 * 3600000).toISOString() }, { username: "Carol Wu", body: "I can lead the onboarding redesign.", created_at: new Date(Date.now() - 68 * 3600000).toISOString() }, { username: "Dave Park", body: "Sounds good, let's schedule a kickoff.", created_at: new Date(Date.now() - 67 * 3600000).toISOString() }, { username: "Alice Chen", body: "Added to next sprint.", created_at: new Date(Date.now() - 66 * 3600000).toISOString() }] }, ], support: [ { id: "t5", from_name: "New User", from_address: "newuser@example.com", subject: "How do I create a space?", status: "open", is_read: false, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 1 * 3600000).toISOString(), body_text: "Hi, I just signed up and I'm not sure how to create my own space. The docs mention a space switcher but I can't find it. Could you point me in the right direction?", comments: [] }, { id: "t6", from_name: "Partner Org", from_address: "partner@example.org", subject: "Integration API access request", status: "open", is_read: true, is_starred: true, comment_count: 2, received_at: new Date(Date.now() - 8 * 3600000).toISOString(), body_text: "We'd like to integrate our platform with rSpace modules via the API. Could you provide API documentation and access credentials for our staging environment?", comments: [{ username: "Team Bot", body: "Request logged. Assigning to API team.", created_at: new Date(Date.now() - 7 * 3600000).toISOString() }, { username: "Bob Martinez", body: "I'll send over the API docs today.", created_at: new Date(Date.now() - 6 * 3600000).toISOString() }] }, { id: "t7", from_name: "Community Member", from_address: "member@example.com", subject: "Feature request: dark mode", status: "closed", is_read: true, is_starred: false, comment_count: 4, received_at: new Date(Date.now() - 96 * 3600000).toISOString(), body_text: "Would love to see a proper dark mode toggle. The current theme is close but some panels still have bright backgrounds.", comments: [{ username: "Carol Wu", body: "This is on our roadmap! Targeting next release.", created_at: new Date(Date.now() - 90 * 3600000).toISOString() }, { username: "Community Member", body: "Awesome, looking forward to it.", created_at: new Date(Date.now() - 88 * 3600000).toISOString() }, { username: "Carol Wu", body: "Dark mode shipped in v2.0!", created_at: new Date(Date.now() - 48 * 3600000).toISOString() }, { username: "Community Member", body: "Looks great, thanks!", created_at: new Date(Date.now() - 46 * 3600000).toISOString() }] }, ], }; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.loadMailboxes(); } private loadDemoData() { this.mailboxes = [ { slug: "team", name: "Team Inbox", email: "team@rspace.online", description: "Shared workspace inbox for internal team communications", thread_count: 4, unread_count: 1 }, { slug: "support", name: "Support", email: "support@rspace.online", description: "Community support requests with multi-sig approval workflows", thread_count: 3, unread_count: 1 }, ]; this.render(); } private async loadMailboxes() { try { const base = window.location.pathname.replace(/\/$/, ""); const resp = await fetch(`${base}/api/mailboxes`); if (resp.ok) { const data = await resp.json(); this.mailboxes = data.mailboxes || []; } } catch { /* ignore */ } this.render(); } private async loadThreads(slug: string) { if (this.space === "demo") { this.threads = this.demoThreads[slug] || []; if (this.filter !== "all") this.threads = this.threads.filter(t => t.status === this.filter); this.render(); return; } try { const base = window.location.pathname.replace(/\/$/, ""); const status = this.filter === "all" ? "" : `?status=${this.filter}`; const resp = await fetch(`${base}/api/mailboxes/${slug}/threads${status}`); if (resp.ok) { const data = await resp.json(); this.threads = data.threads || []; } } catch { /* ignore */ } this.render(); } private async loadThread(id: string) { if (this.space === "demo") { const all = [...(this.demoThreads.team || []), ...(this.demoThreads.support || [])]; this.currentThread = all.find(t => t.id === id) || null; if (this.currentThread) { this.currentThread.comments = this.currentThread.comments || [{ username: "Team Bot", body: "Thread noted.", created_at: new Date().toISOString() }]; } this.render(); return; } try { const base = window.location.pathname.replace(/\/$/, ""); const resp = await fetch(`${base}/api/threads/${id}`); if (resp.ok) { this.currentThread = await resp.json(); } } catch { /* ignore */ } this.render(); } private async loadApprovals() { if (this.space === "demo") { this.approvals = this.demoApprovals; this.render(); return; } try { const base = window.location.pathname.replace(/\/$/, ""); const q = this.currentMailbox ? `?mailbox=${this.currentMailbox.slug}` : ""; const resp = await fetch(`${base}/api/approvals${q}`); if (resp.ok) { const data = await resp.json(); this.approvals = data.approvals || []; } } catch { /* ignore */ } this.render(); } private timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } private snippet(text: string, len = 80): string { if (!text) return ""; const s = text.replace(/\n+/g, " ").trim(); return s.length > len ? s.slice(0, len) + "..." : s; } private render() { this.shadow.innerHTML = `
${this.renderNav()} ${this.renderView()} ${this.helpOpen ? this.renderHelp() : ""}
`; this.bindEvents(); } private renderNav(): string { const items = [ { id: "mailboxes", label: "Mailboxes" }, { id: "approvals", label: "Approvals" }, ]; if (this.currentMailbox) { items.unshift({ id: "threads", label: this.currentMailbox.name }); } return `
${this.view !== "mailboxes" ? `` : ""} ${items.map((i) => ``).join("")}
`; } private renderView(): string { switch (this.view) { case "mailboxes": return this.renderMailboxes(); case "threads": return this.renderThreads(); case "thread": return this.renderThreadDetail(); case "approvals": return this.renderApprovals(); default: return ""; } } private renderMailboxes(): string { if (this.mailboxes.length === 0) { return `

📨

No mailboxes yet

Create a shared mailbox for your team. Members can triage, comment on, and co-sign outgoing emails.

`; } return `
${this.mailboxes.map((m) => { const demoThreads = this.demoThreads[m.slug] || []; const threadCount = m.thread_count ?? demoThreads.length; const unreadCount = m.unread_count ?? demoThreads.filter((t: any) => !t.is_read).length; return `
📨
${m.name}
${m.email}
${m.description || "Shared mailbox"}
${threadCount} thread${threadCount !== 1 ? "s" : ""} ${unreadCount > 0 ? `${unreadCount} unread` : ""}
`; }).join("")}
`; } private renderThreads(): string { const filters = ["all", "open", "snoozed", "closed"]; return `
${filters.map((f) => ``).join("")}
${this.threads.length === 0 ? `

No threads${this.filter !== "all" ? ` with status "${this.filter}"` : ""}

` : this.threads.map((t) => `
${t.from_name || t.from_address || "Unknown"} ${this.timeAgo(t.received_at)}
${t.subject}
${this.snippet(t.body_text)}
${t.is_starred ? `` : ""} ${t.status} ${t.comment_count > 0 ? `💬 ${t.comment_count}` : ""}
`).join("")}
`; } private renderThreadDetail(): string { if (!this.currentThread) return `
Loading...
`; const t = this.currentThread; const comments = t.comments || []; return `
${t.subject}
From: ${t.from_name || ""} <${t.from_address || "unknown"}> · ${this.timeAgo(t.received_at)} · ${t.status} ${t.is_starred ? `` : ""}
${t.body_text || t.body_html || "(no content)"}
💬 Internal Comments (${comments.length})
${comments.map((cm: any) => `
${cm.username || "Anonymous"} ${this.timeAgo(cm.created_at)}
${cm.body}
`).join("")} ${comments.length === 0 ? `
No comments yet — discuss this thread with your team before replying.
` : ""}
`; } private renderApprovals(): string { const composeForm = this.composeOpen ? `

✎ Draft for Multi-Sig Approval

🔒 This email requires 2 of 3 team member signatures before it will be sent.
` : ""; if (this.approvals.length === 0 && !this.composeOpen) { return `

No pending approvals

When a team member drafts an outgoing email, it appears here for multi-sig approval. Collect the required signatures before it sends.

`; } const cards = this.approvals.map((a) => { const pct = a.required_signatures > 0 ? Math.min(100, Math.round((a.signature_count / a.required_signatures) * 100)) : 0; const statusClass = a.status.toLowerCase(); const signers = (a.signers || []).map((s: any) => { const initials = (s.name || "?").split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase(); const isSigned = s.vote === "APPROVE"; return `
${initials}
${s.name} ${isSigned ? "✓ Signed " + this.timeAgo(s.signed_at) : "Awaiting signature"}
`; }).join(""); return `
${a.subject}
${a.status}
To: ${(a.to_addresses || []).map((e: string) => `${e}`).join(", ")}
${this.timeAgo(a.created_at)}
${a.body_text ? `
${this.snippet(a.body_text, 200)}
` : ""}
${a.signature_count || 0} / ${a.required_signatures} signatures
${signers ? `
${signers}
` : ""} ${a.status === "PENDING" ? `
` : ""}
`; }).join(""); return ` ${composeForm} ${cards} `; } private renderHelp(): string { return `
📨 rInbox Guide
Collaborative email with multi-sig approval workflows. Shared mailboxes for your team where outgoing emails require collective sign-off.
📨

Shared Mailboxes

Create team mailboxes that multiple members can read, triage, and respond to with role-based access.

💬

Threaded Comments

Discuss emails internally before replying. @mention teammates and coordinate responses.

Multi-Sig Approval

Outgoing emails require M-of-N signatures. Board votes and treasury approvals built into email.

🔄

IMAP Sync

Connect any IMAP mailbox. Server-side polling syncs emails automatically every 30 seconds.

How It Works

1 Create a mailbox — set up a shared inbox for your team with an email address and invite members.
2 Triage & discuss — incoming emails sync via IMAP. Team members triage threads, leave comments, and star important messages.
3 Approve & send — draft a reply, then collect the required signatures. Once the threshold is met, the email sends.

Use Cases

Governance — board decisions require 3-of-5 signers before the email sends.
💰 Treasury — 2-of-3 finance team co-sign payment authorizations. Bridges email to on-chain wallets.
🔎 Audit Trails — every email is co-signed with cryptographic proof of who approved what.
View Full Feature Page →
`; } private bindEvents() { // Navigation this.shadow.querySelectorAll("[data-nav]").forEach((btn) => { btn.addEventListener("click", () => { const nav = (btn as HTMLElement).dataset.nav as any; if (nav === "approvals") { this.view = "approvals"; this.loadApprovals(); } else if (nav === "mailboxes") { this.view = "mailboxes"; this.currentMailbox = null; this.render(); } else if (nav === "threads") { this.view = "threads"; this.render(); } }); }); // Back const backBtn = this.shadow.querySelector("[data-action='back']"); if (backBtn) { backBtn.addEventListener("click", () => { if (this.view === "thread") { this.view = "threads"; this.render(); } else if (this.view === "threads" || this.view === "approvals") { this.view = "mailboxes"; this.currentMailbox = null; this.render(); } }); } // Help this.shadow.querySelectorAll("[data-action='help']").forEach((btn) => { btn.addEventListener("click", () => { this.helpOpen = true; this.render(); }); }); const helpOverlay = this.shadow.querySelector("[data-action='close-help-overlay']"); if (helpOverlay) { helpOverlay.addEventListener("click", (e) => { if ((e.target as HTMLElement).dataset.action === "close-help-overlay") { this.helpOpen = false; this.render(); } }); } const helpClose = this.shadow.querySelector("[data-action='close-help']"); if (helpClose) { helpClose.addEventListener("click", () => { this.helpOpen = false; this.render(); }); } // Mailbox click this.shadow.querySelectorAll("[data-mailbox]").forEach((card) => { card.addEventListener("click", () => { const slug = (card as HTMLElement).dataset.mailbox!; this.currentMailbox = this.mailboxes.find((m) => m.slug === slug); this.view = "threads"; this.loadThreads(slug); }); }); // Thread click this.shadow.querySelectorAll("[data-thread]").forEach((row) => { row.addEventListener("click", () => { const id = (row as HTMLElement).dataset.thread!; this.view = "thread"; this.loadThread(id); }); }); // Filter this.shadow.querySelectorAll("[data-filter]").forEach((btn) => { btn.addEventListener("click", () => { this.filter = (btn as HTMLElement).dataset.filter as any; if (this.currentMailbox) this.loadThreads(this.currentMailbox.slug); }); }); // Compose const composeBtn = this.shadow.querySelector("[data-action='compose']"); if (composeBtn) { composeBtn.addEventListener("click", () => { this.composeOpen = !this.composeOpen; this.render(); }); } const cancelCompose = this.shadow.querySelector("[data-action='cancel-compose']"); if (cancelCompose) { cancelCompose.addEventListener("click", () => { this.composeOpen = false; this.render(); }); } const submitCompose = this.shadow.querySelector("[data-action='submit-compose']"); if (submitCompose) { submitCompose.addEventListener("click", async () => { if (this.space === "demo") { alert("Compose is disabled in demo mode. In a live space, this would submit the email for team approval."); return; } const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim(); const subject = (this.shadow.getElementById("compose-subject") as HTMLInputElement)?.value.trim(); const body = (this.shadow.getElementById("compose-body") as HTMLTextAreaElement)?.value.trim(); if (!to || !subject || !body) { alert("Please fill all fields."); return; } try { const base = window.location.pathname.replace(/\/$/, ""); const mailbox = this.currentMailbox?.slug || ""; await fetch(`${base}/api/approvals`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to_addresses: [to], subject, body_text: body, mailbox }), }); this.composeOpen = false; this.loadApprovals(); } catch { alert("Failed to submit. Please try again."); } }); } // Approval actions this.shadow.querySelectorAll("[data-approve]").forEach((btn) => { btn.addEventListener("click", async () => { if (this.space === "demo") { alert("Approvals are disabled in demo mode."); return; } const id = (btn as HTMLElement).dataset.approve!; const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vote: "APPROVE" }), }); this.loadApprovals(); }); }); this.shadow.querySelectorAll("[data-reject]").forEach((btn) => { btn.addEventListener("click", async () => { if (this.space === "demo") { alert("Approvals are disabled in demo mode."); return; } const id = (btn as HTMLElement).dataset.reject!; const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vote: "REJECT" }), }); this.loadApprovals(); }); }); } } customElements.define("folk-inbox-client", FolkInboxClient);