feat(rinbox): reply/forward, SMTP send, personal/agent inboxes, canvas shape

Add reply, reply-all, and forward endpoints with proper RFC 5322 threading
headers (In-Reply-To, References). SMTP send executes automatically when
approval threshold is met via nodemailer. Personal inbox CRUD lets users
connect their own IMAP accounts. Agent inbox system with regex-based rules
for auto-classify/auto-reply (drafts go through approval workflow).
Multi-sig email canvas shape (folk-multisig-email) with draft/pending/sent
states and 5s polling. Per-space auto-provisioning via onSpaceCreate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 09:50:39 +00:00
parent de2a1baf84
commit 61b25e299f
5 changed files with 1800 additions and 64 deletions

514
lib/folk-multisig-email.ts Normal file
View File

@ -0,0 +1,514 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
min-width: 360px;
min-height: 320px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #6366f1;
color: white;
font-size: 12px;
font-weight: 600;
cursor: move;
border-radius: 8px 8px 0 0;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.status-draft { background: #334155; color: #94a3b8; }
.status-pending { background: #fbbf24; color: #1e293b; }
.status-approved { background: #22c55e; color: white; }
.status-sent { background: #22c55e; color: white; }
.status-rejected { background: #ef4444; color: white; }
.body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
height: calc(100% - 36px);
overflow-y: auto;
}
.field {
display: flex;
gap: 6px;
font-size: 12px;
align-items: baseline;
}
.field-label {
color: #64748b;
font-weight: 600;
min-width: 50px;
flex-shrink: 0;
}
.field-value {
color: #cbd5e1;
word-break: break-all;
}
.field-input {
flex: 1;
background: rgba(0,0,0,0.3);
border: 1px solid #334155;
border-radius: 4px;
padding: 3px 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
outline: none;
}
.field-input:focus { border-color: #6366f1; }
.field-textarea {
flex: 1;
background: rgba(0,0,0,0.3);
border: 1px solid #334155;
border-radius: 4px;
padding: 4px 6px;
color: #e2e8f0;
font-size: 12px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 48px;
max-height: 120px;
}
.field-textarea:focus { border-color: #6366f1; }
.divider {
border: none;
border-top: 1px solid #1e293b;
margin: 4px 0;
}
.body-preview {
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
max-height: 60px;
overflow: hidden;
padding: 6px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
border-left: 3px solid #334155;
}
/* Progress */
.progress-section {
margin-top: auto;
}
.progress-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.progress-bar {
flex: 1;
height: 6px;
background: #1e293b;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.progress-fill.approved, .progress-fill.sent { background: linear-gradient(90deg, #22c55e, #16a34a); }
.progress-text {
font-size: 10px;
color: #64748b;
flex-shrink: 0;
}
.signer-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 2px 0;
}
.signer-dot {
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
color: white;
flex-shrink: 0;
}
.signer-dot.signed { background: #22c55e; }
.signer-dot.waiting { background: #334155; }
.signer-name { flex: 1; }
.signer-name.signed { color: #e2e8f0; }
.signer-name.waiting { color: #64748b; }
.signer-status { font-size: 10px; font-weight: 600; }
.signer-status.signed { color: #4ade80; }
.signer-status.waiting { color: #64748b; }
/* Actions */
.actions {
display: flex;
gap: 6px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #1e293b;
}
.btn {
padding: 4px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #6366f1; color: white; }
.btn-success { background: #22c55e; color: white; }
.btn-danger { background: #ef4444; color: white; }
.btn-secondary { background: #334155; color: #94a3b8; }
`;
export class FolkMultisigEmail extends FolkShape {
static tagName = "folk-multisig-email";
// Shape data
mailboxSlug = "";
toAddresses: string[] = [];
ccAddresses: string[] = [];
subject = "";
bodyText = "";
bodyHtml = "";
replyToThreadId: string | null = null;
replyType: 'new' | 'reply' | 'forward' = 'new';
approvalId: string | null = null;
status: 'draft' | 'pending' | 'approved' | 'sent' | 'rejected' = 'draft';
requiredSignatures = 2;
signatures: Array<{ id: string; signerId: string; vote: string; signedAt: string }> = [];
private _pollInterval: ReturnType<typeof setInterval> | null = null;
static get observedAttributes() {
return [...super.observedAttributes, "mailbox-slug", "subject", "status", "approval-id"];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
super.attributeChangedCallback(name, oldValue, newValue);
switch (name) {
case "mailbox-slug": this.mailboxSlug = newValue || ""; break;
case "subject": this.subject = newValue || ""; break;
case "status": this.status = (newValue || "draft") as any; break;
case "approval-id": this.approvalId = newValue || null; break;
}
}
connectedCallback() {
super.connectedCallback();
this.shadowRoot!.adoptedStyleSheets = [...this.shadowRoot!.adoptedStyleSheets, styles];
this.renderContent();
if (this.status === 'pending' && this.approvalId) {
this.startPolling();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopPolling();
}
private getApiBase(): string {
const space = (this as any).spaceSlug || (window as any).__communitySync?.communitySlug || "demo";
return `/${space}/rinbox`;
}
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const token = localStorage.getItem("encryptid-token");
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
private startPolling() {
this.stopPolling();
this._pollInterval = setInterval(() => this.pollApproval(), 5000);
}
private stopPolling() {
if (this._pollInterval) {
clearInterval(this._pollInterval);
this._pollInterval = null;
}
}
private async pollApproval() {
if (!this.approvalId) return;
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}`, {
headers: this.getAuthHeaders(),
});
if (!resp.ok) return;
const data = await resp.json();
const newStatus = data.status?.toLowerCase();
if (newStatus && newStatus !== this.status) {
this.status = newStatus;
this.signatures = data.signatures || [];
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
if (newStatus !== 'pending') this.stopPolling();
} else {
// Update signature count even if status didn't change
if (data.signatures) {
this.signatures = data.signatures;
this.renderContent();
}
}
} catch { /* ignore polling errors */ }
}
private renderContent() {
const sr = this.shadowRoot;
if (!sr) return;
const isDraft = this.status === 'draft';
const sigCount = this.signatures.filter(s => s.vote === 'APPROVE').length;
const pct = this.requiredSignatures > 0 ? Math.min(100, Math.round((sigCount / this.requiredSignatures) * 100)) : 0;
let contentEl = sr.querySelector(".msig-content") as HTMLDivElement;
if (!contentEl) {
contentEl = document.createElement("div");
contentEl.className = "msig-content";
contentEl.style.cssText = "display:flex;flex-direction:column;height:100%;";
sr.appendChild(contentEl);
}
if (isDraft) {
contentEl.innerHTML = html`
<div class="header">
<div class="header-title">&#9993; Multi-Sig Email</div>
<span class="status-badge status-draft">Draft</span>
</div>
<div class="body">
<div class="field">
<span class="field-label">From:</span>
<span class="field-value" style="color:#6366f1">${this.mailboxSlug || "team"}@rspace.online</span>
</div>
<div class="field">
<span class="field-label">To:</span>
<input class="field-input" data-field="to" value="${this.toAddresses.join(', ')}" placeholder="recipient@example.com" />
</div>
<div class="field">
<span class="field-label">Subject:</span>
<input class="field-input" data-field="subject" value="${this.escapeAttr(this.subject)}" placeholder="Email subject" />
</div>
<hr class="divider" />
<textarea class="field-textarea" data-field="body" placeholder="Write your email...">${this.escapeHtml(this.bodyText)}</textarea>
<div class="actions">
<button class="btn btn-primary" data-action="submit">Submit for Approval</button>
</div>
</div>
`;
} else {
const snippet = this.bodyText.length > 120 ? this.bodyText.slice(0, 120) + '...' : this.bodyText;
contentEl.innerHTML = html`
<div class="header" style="background:${this.status === 'sent' || this.status === 'approved' ? '#16a34a' : this.status === 'rejected' ? '#dc2626' : '#6366f1'}">
<div class="header-title">&#9993; Multi-Sig Email</div>
<span class="status-badge status-${this.status}">${this.status}</span>
</div>
<div class="body">
<div class="field">
<span class="field-label">From:</span>
<span class="field-value" style="color:#6366f1">${this.mailboxSlug || "team"}@rspace.online</span>
</div>
<div class="field">
<span class="field-label">To:</span>
<span class="field-value">${this.toAddresses.join(', ') || '(none)'}</span>
</div>
<div class="field">
<span class="field-label">Subject:</span>
<span class="field-value" style="font-weight:600">${this.subject || '(no subject)'}</span>
</div>
<hr class="divider" />
<div class="body-preview">${this.escapeHtml(snippet)}</div>
<div class="progress-section">
<div class="progress-bar-wrap">
<div class="progress-bar">
<div class="progress-fill ${this.status}" style="width:${pct}%"></div>
</div>
<span class="progress-text">${sigCount}/${this.requiredSignatures} signatures</span>
</div>
${this.signatures.map(s => html`
<div class="signer-row">
<div class="signer-dot ${s.vote === 'APPROVE' ? 'signed' : 'waiting'}">${s.vote === 'APPROVE' ? '&#10003;' : ''}</div>
<span class="signer-name ${s.vote === 'APPROVE' ? 'signed' : 'waiting'}">${s.signerId?.slice(0, 12) || 'Unknown'}...</span>
<span class="signer-status ${s.vote === 'APPROVE' ? 'signed' : 'waiting'}">${s.vote === 'APPROVE' ? 'Signed' : 'Awaiting'}</span>
</div>
`).join('')}
</div>
${this.status === 'pending' ? html`
<div class="actions">
<button class="btn btn-success" data-action="approve">&#10003; Approve</button>
<button class="btn btn-danger" data-action="reject">&#10007; Reject</button>
</div>
` : ''}
${this.status === 'draft' ? html`
<div class="actions">
<button class="btn btn-secondary" data-action="edit">Edit</button>
<button class="btn btn-primary" data-action="submit">Submit</button>
</div>
` : ''}
</div>
`;
}
this.bindShapeEvents(contentEl);
}
private escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
private escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
private bindShapeEvents(root: HTMLElement) {
// Draft field changes
root.querySelectorAll("[data-field]").forEach(el => {
el.addEventListener("input", () => {
const field = (el as HTMLElement).dataset.field;
const value = (el as HTMLInputElement | HTMLTextAreaElement).value;
switch (field) {
case 'to':
this.toAddresses = value.split(',').map(s => s.trim()).filter(Boolean);
break;
case 'subject':
this.subject = value;
break;
case 'body':
this.bodyText = value;
break;
}
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
});
});
// Submit
root.querySelector("[data-action='submit']")?.addEventListener("click", async () => {
if (!this.subject || this.toAddresses.length === 0) {
alert("Please fill To and Subject fields.");
return;
}
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({
mailbox_slug: this.mailboxSlug || undefined,
subject: this.subject,
body_text: this.bodyText,
to_addresses: this.toAddresses,
cc_addresses: this.ccAddresses,
reply_type: this.replyType,
thread_id: this.replyToThreadId,
}),
});
if (resp.ok) {
const data = await resp.json();
this.approvalId = data.id;
this.status = 'pending';
this.requiredSignatures = data.required_signatures || 2;
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
this.startPolling();
}
} catch (e) {
console.error("[MultiSigEmail] Submit error:", e);
}
});
// Approve
root.querySelector("[data-action='approve']")?.addEventListener("click", async () => {
if (!this.approvalId) return;
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ vote: "APPROVE" }),
});
if (resp.ok) {
const data = await resp.json();
if (data.status) {
this.status = data.status.toLowerCase();
if (this.status !== 'pending') this.stopPolling();
}
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
}
} catch (e) {
console.error("[MultiSigEmail] Approve error:", e);
}
});
// Reject
root.querySelector("[data-action='reject']")?.addEventListener("click", async () => {
if (!this.approvalId) return;
try {
const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ vote: "REJECT" }),
});
if (resp.ok) {
const data = await resp.json();
if (data.status) this.status = data.status.toLowerCase();
this.stopPolling();
this.renderContent();
this.dispatchEvent(new CustomEvent("content-change", { bubbles: true }));
}
} catch (e) {
console.error("[MultiSigEmail] Reject error:", e);
}
});
}
}
customElements.define(FolkMultisigEmail.tagName, FolkMultisigEmail);

View File

@ -2,32 +2,42 @@
* folk-inbox-client Collaborative email client.
*
* Shows mailbox list, thread inbox, thread detail with comments,
* and approval workflow interface. Includes a help/guide popout.
* reply/forward compose, approval workflow, personal inboxes,
* and agent inbox management. Includes a help/guide popout.
*/
import { mailboxSchema, type MailboxDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward';
class FolkInboxClient extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private _offlineUnsubs: (() => void)[] = [];
private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes";
private view: "mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents" = "mailboxes";
private mailboxes: any[] = [];
private threads: any[] = [];
private currentMailbox: any = null;
private currentThread: any = null;
private approvals: any[] = [];
private personalInboxes: any[] = [];
private agentInboxes: any[] = [];
private filter: "all" | "open" | "snoozed" | "closed" = "all";
private helpOpen = false;
private composeOpen = false;
private composeMode: ComposeMode = 'new';
private showingSampleData = false;
private connectFormOpen = false;
private agentFormOpen = false;
private _usernameCache = new Map<string, string>();
private _currentUsername: string | null = null;
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(),
created_at: new Date(Date.now() - 4 * 3600000).toISOString(), reply_type: 'reply',
signers: [
{ name: "Alice Chen", vote: "APPROVE", signed_at: new Date(Date.now() - 3 * 3600000).toISOString() },
{ name: "Bob Martinez", vote: null, signed_at: null },
@ -38,7 +48,7 @@ class FolkInboxClient extends HTMLElement {
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(),
created_at: new Date(Date.now() - 12 * 3600000).toISOString(), reply_type: 'new',
signers: [
{ name: "Dave Park", vote: "APPROVE", signed_at: new Date(Date.now() - 10 * 3600000).toISOString() },
{ name: "Alice Chen", vote: null, signed_at: null },
@ -48,7 +58,7 @@ class FolkInboxClient extends HTMLElement {
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(),
created_at: new Date(Date.now() - 48 * 3600000).toISOString(), reply_type: 'reply',
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() },
@ -57,15 +67,15 @@ class FolkInboxClient extends HTMLElement {
];
private demoThreads: Record<string, any[]> = {
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() }] },
{ 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.", direction: 'inbound', 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", direction: 'inbound', 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.", direction: 'inbound', 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.", direction: 'inbound', 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() }] },
{ 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?", direction: 'inbound', 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?", direction: 'inbound', 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.", direction: 'inbound', 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() }] },
],
};
@ -76,6 +86,7 @@ class FolkInboxClient extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this._loadUsername();
if (this.space === "demo") { this.loadDemoData(); return; }
this.subscribeOffline();
this.loadMailboxes();
@ -86,6 +97,25 @@ class FolkInboxClient extends HTMLElement {
this._offlineUnsubs = [];
}
/** Extract username from EncryptID JWT in localStorage */
private _loadUsername() {
try {
const token = localStorage.getItem('encryptid-token');
if (!token) return;
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.username) this._currentUsername = payload.username;
} catch { /* no token or invalid */ }
}
/** Resolve a DID to a display name */
private displayName(did: string | null | undefined): string {
if (!did) return 'Anonymous';
if (did.startsWith('agent:')) return `Bot (${did.slice(6, 14)}...)`;
if (this._usernameCache.has(did)) return this._usernameCache.get(did)!;
// Truncate DID for display
return did.length > 20 ? did.slice(0, 8) + '...' + did.slice(-6) : did;
}
private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
@ -191,6 +221,54 @@ class FolkInboxClient extends HTMLElement {
this.render();
}
private async loadPersonalInboxes() {
if (this.space === "demo" || this.showingSampleData) {
this.personalInboxes = [
{ id: "pi1", label: "Gmail", email: "user@gmail.com", status: "active", last_sync_at: new Date(Date.now() - 60000).toISOString() },
];
this.render();
return;
}
try {
const base = window.location.pathname.replace(/\/$/, "");
const token = localStorage.getItem('encryptid-token');
const resp = await fetch(`${base}/api/personal-inboxes`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (resp.ok) {
const data = await resp.json();
this.personalInboxes = data.personal_inboxes || [];
}
} catch { /* ignore */ }
this.render();
}
private async loadAgentInboxes() {
if (this.space === "demo" || this.showingSampleData) {
this.agentInboxes = [
{ id: "ai1", name: "Support Bot", email: "support-bot@rspace.online", auto_reply: true, auto_classify: true, rules: [{ match: { field: 'subject', pattern: 'urgent' }, action: 'tag', value: 'urgent' }] },
];
this.render();
return;
}
try {
const base = window.location.pathname.replace(/\/$/, "");
const resp = await fetch(`${base}/api/agent-inboxes?space=${this.space}`);
if (resp.ok) {
const data = await resp.json();
this.agentInboxes = data.agent_inboxes || [];
}
} catch { /* ignore */ }
this.render();
}
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const token = localStorage.getItem('encryptid-token');
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
private timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
@ -214,7 +292,7 @@ class FolkInboxClient extends HTMLElement {
.container { max-width: 1000px; margin: 0 auto; }
/* Nav */
.rapp-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; min-height: 36px; }
.rapp-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; min-height: 36px; flex-wrap: wrap; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.nav-btn { padding: 0.4rem 1rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; }
@ -263,7 +341,10 @@ class FolkInboxClient extends HTMLElement {
.badge-closed { background: rgba(100,116,139,0.15); color: #94a3b8; }
.badge-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; }
.badge-sent { background: rgba(34,197,94,0.15); color: #4ade80; }
.badge-comments { background: rgba(99,102,241,0.15); color: #818cf8; }
.badge-outbound { background: rgba(96,165,250,0.15); color: #60a5fa; }
.badge-bot { background: rgba(168,85,247,0.15); color: #a855f7; }
.star { color: #fbbf24; font-size: 0.7rem; }
/* Thread detail */
@ -272,6 +353,13 @@ class FolkInboxClient extends HTMLElement {
.detail-subject { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
.detail-meta { font-size: 0.8rem; color: #64748b; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.detail-body { font-size: 0.9rem; line-height: 1.6; color: #cbd5e1; margin-bottom: 1.5rem; white-space: pre-wrap; }
/* Thread actions */
.thread-actions { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; padding: 0.75rem 0; border-top: 1px solid #1e293b; border-bottom: 1px solid #1e293b; }
.thread-actions button { padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; }
.thread-actions button:hover { border-color: #6366f1; color: #818cf8; background: rgba(99,102,241,0.1); }
/* Comments */
.comments-section { border-top: 1px solid #1e293b; padding-top: 1rem; }
.comments-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.75rem; color: #94a3b8; }
.comment { padding: 0.75rem; background: rgba(0,0,0,0.2); border-radius: 8px; margin-bottom: 0.5rem; border-left: 3px solid #334155; }
@ -284,7 +372,7 @@ class FolkInboxClient extends HTMLElement {
/* Approval cards */
.approval-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; border-left: 4px solid #334155; transition: border-color 0.2s; }
.approval-card.status-pending { border-left-color: #fbbf24; }
.approval-card.status-approved { border-left-color: #22c55e; }
.approval-card.status-approved, .approval-card.status-sent { border-left-color: #22c55e; }
.approval-card.status-rejected { border-left-color: #ef4444; }
.approval-to { font-size: 0.8rem; color: #64748b; margin-bottom: 0.5rem; }
.approval-to code { font-size: 0.75rem; color: #818cf8; background: rgba(99,102,241,0.1); padding: 0.1rem 0.4rem; border-radius: 4px; }
@ -293,7 +381,7 @@ class FolkInboxClient extends HTMLElement {
.progress-bar { flex: 1; height: 8px; background: #1e293b; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
.progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.progress-fill.approved { background: linear-gradient(90deg, #22c55e, #16a34a); }
.progress-fill.approved, .progress-fill.sent { background: linear-gradient(90deg, #22c55e, #16a34a); }
.progress-text { font-size: 0.75rem; color: #94a3b8; flex-shrink: 0; }
.signer-list { margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.35rem; }
.signer-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; }
@ -329,6 +417,21 @@ class FolkInboxClient extends HTMLElement {
.btn-cancel { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; }
.btn-cancel:hover { border-color: #475569; color: #e2e8f0; }
/* Personal / Agent cards */
.inbox-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 1rem; }
.inbox-card-info { flex: 1; }
.inbox-card-label { font-weight: 600; font-size: 0.9rem; }
.inbox-card-email { font-size: 0.8rem; color: #6366f1; font-family: monospace; }
.inbox-card-status { font-size: 0.7rem; }
.inbox-card-status.active { color: #4ade80; }
.inbox-card-status.error { color: #ef4444; }
.inbox-card-status.paused { color: #fbbf24; }
.inbox-card-actions button { padding: 0.3rem 0.75rem; border-radius: 6px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.75rem; }
.inbox-card-actions button:hover { border-color: #ef4444; color: #ef4444; }
.agent-toggle { font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; }
.agent-toggle .on { color: #4ade80; }
.agent-toggle .off { color: #64748b; }
/* Help panel */
.help-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.help-panel { background: #0f172a; border: 1px solid #334155; border-radius: 16px; max-width: 640px; width: 100%; max-height: 80vh; overflow-y: auto; padding: 2rem; position: relative; }
@ -369,9 +472,11 @@ class FolkInboxClient extends HTMLElement {
}
private renderNav(): string {
const items = [
const items: { id: string; label: string }[] = [
{ id: "mailboxes", label: "Mailboxes" },
{ id: "approvals", label: "Approvals" },
{ id: "personal", label: "Personal" },
{ id: "agents", label: "Agents" },
];
if (this.currentMailbox) {
items.unshift({ id: "threads", label: this.currentMailbox.name });
@ -381,6 +486,7 @@ class FolkInboxClient extends HTMLElement {
${this.view !== "mailboxes" ? `<button class="rapp-nav__back" data-action="back">&larr;</button>` : ""}
${items.map((i) => `<button class="nav-btn ${this.view === i.id ? "active" : ""}" data-nav="${i.id}">${i.label}</button>`).join("")}
<span class="nav-spacer"></span>
${this._currentUsername ? `<span style="font-size:0.75rem;color:#818cf8;margin-right:0.5rem">${this._currentUsername}</span>` : ''}
<button class="help-btn" data-action="help">? Guide</button>
</div>
`;
@ -392,6 +498,8 @@ class FolkInboxClient extends HTMLElement {
case "threads": return this.renderThreads();
case "thread": return this.renderThreadDetail();
case "approvals": return this.renderApprovals();
case "personal": return this.renderPersonal();
case "agents": return this.renderAgents();
default: return "";
}
}
@ -448,6 +556,7 @@ class FolkInboxClient extends HTMLElement {
<div class="thread-tags">
${t.is_starred ? `<span class="star">&#9733;</span>` : ""}
<span class="badge badge-${t.status}">${t.status}</span>
${t.direction === 'outbound' ? `<span class="badge badge-outbound">sent</span>` : ''}
${t.comment_count > 0 ? `<span class="badge badge-comments">&#128172; ${t.comment_count}</span>` : ""}
</div>
</div>
@ -461,6 +570,7 @@ class FolkInboxClient extends HTMLElement {
if (!this.currentThread) return `<div class="empty">Loading...</div>`;
const t = this.currentThread;
const comments = t.comments || [];
const composeHtml = this.composeOpen ? this.renderInlineCompose() : '';
return `
<div class="detail-panel">
<div class="detail-header">
@ -471,16 +581,23 @@ class FolkInboxClient extends HTMLElement {
<span>${this.timeAgo(t.received_at)}</span>
<span>&middot;</span>
<span class="badge badge-${t.status}">${t.status}</span>
${t.direction === 'outbound' ? `<span class="badge badge-outbound">sent</span>` : ''}
${t.is_starred ? `<span class="star">&#9733;</span>` : ""}
</div>
</div>
<div class="detail-body">${t.body_text || t.body_html || "(no content)"}</div>
<div class="thread-actions">
<button data-action="reply">&#8617; Reply</button>
<button data-action="reply-all">&#8617;&#8617; Reply All</button>
<button data-action="forward">&#8618; Forward</button>
</div>
${composeHtml}
<div class="comments-section">
<div class="comments-title">&#128172; Internal Comments (${comments.length})</div>
${comments.map((cm: any) => `
<div class="comment">
<div class="comment-top">
<span class="comment-author">${cm.username || "Anonymous"}</span>
<span class="comment-author">${cm.username || this.displayName(cm.author_id) || "Anonymous"}</span>
<span class="comment-time">${this.timeAgo(cm.created_at)}</span>
</div>
<div class="comment-body">${cm.body}</div>
@ -492,8 +609,75 @@ class FolkInboxClient extends HTMLElement {
`;
}
private renderInlineCompose(): string {
const t = this.currentThread;
let defaultTo = '';
let defaultSubject = '';
let defaultBody = '';
const modeLabel = this.composeMode === 'reply' ? 'Reply' :
this.composeMode === 'reply-all' ? 'Reply All' :
this.composeMode === 'forward' ? 'Forward' : 'New Email';
if (t) {
if (this.composeMode === 'reply') {
defaultTo = t.from_address || '';
defaultSubject = t.subject?.startsWith('Re:') ? t.subject : `Re: ${t.subject || ''}`;
const date = new Date(t.received_at).toLocaleString();
const from = t.from_name ? `${t.from_name} <${t.from_address}>` : (t.from_address || '');
defaultBody = `\n\nOn ${date}, ${from} wrote:\n` +
(t.body_text || '').split('\n').map((l: string) => `> ${l}`).join('\n');
} else if (this.composeMode === 'reply-all') {
const allAddrs = [t.from_address, ...(t.to_addresses || [])].filter(Boolean);
defaultTo = allAddrs.join(', ');
defaultSubject = t.subject?.startsWith('Re:') ? t.subject : `Re: ${t.subject || ''}`;
const date = new Date(t.received_at).toLocaleString();
const from = t.from_name ? `${t.from_name} <${t.from_address}>` : (t.from_address || '');
defaultBody = `\n\nOn ${date}, ${from} wrote:\n` +
(t.body_text || '').split('\n').map((l: string) => `> ${l}`).join('\n');
} else if (this.composeMode === 'forward') {
defaultSubject = t.subject?.startsWith('Fwd:') ? t.subject : `Fwd: ${t.subject || ''}`;
const date = new Date(t.received_at).toLocaleString();
defaultBody = '\n\n---------- Forwarded message ----------\n' +
`From: ${t.from_name || ''} <${t.from_address || ''}>\n` +
`Date: ${date}\n` +
`Subject: ${t.subject || ''}\n` +
`To: ${(t.to_addresses || []).join(', ')}\n\n` +
(t.body_text || '');
}
}
return `
<div class="compose-panel">
<h3>&#9998; ${modeLabel}</h3>
<div class="compose-field">
<label>To</label>
<input id="compose-to" type="text" placeholder="recipient@example.com" value="${this.escapeHtml(defaultTo)}" />
</div>
<div class="compose-field">
<label>Subject</label>
<input id="compose-subject" type="text" value="${this.escapeHtml(defaultSubject)}" />
</div>
<div class="compose-field">
<label>Body</label>
<textarea id="compose-body" placeholder="Write your message...">${this.escapeHtml(defaultBody)}</textarea>
</div>
<div class="compose-threshold">
&#128274; This email will be submitted for multi-sig approval before sending.
</div>
<div class="compose-actions">
<button class="btn-submit" data-action="submit-reply">Submit for Approval</button>
<button class="btn-cancel" data-action="cancel-compose">Cancel</button>
</div>
</div>
`;
}
private escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
private renderApprovals(): string {
const composeForm = this.composeOpen ? `
const composeForm = this.composeOpen && this.view === 'approvals' ? `
<div class="compose-panel">
<h3>&#9998; Draft for Multi-Sig Approval</h3>
<div class="compose-field">
@ -509,7 +693,7 @@ class FolkInboxClient extends HTMLElement {
<textarea id="compose-body" placeholder="Write the email body. This will be reviewed and co-signed by your team before sending."></textarea>
</div>
<div class="compose-threshold">
&#128274; This email requires <strong style="color:#818cf8">2 of 3</strong> team member signatures before it will be sent.
&#128274; This email requires <strong style="color:#818cf8">multi-sig</strong> team member signatures before it will be sent.
</div>
<div class="compose-actions">
<button class="btn-submit" data-action="submit-compose">Submit for Approval</button>
@ -534,6 +718,7 @@ class FolkInboxClient extends HTMLElement {
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 isAgentDraft = a.author_id?.startsWith('agent:');
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";
@ -547,10 +732,18 @@ class FolkInboxClient extends HTMLElement {
</div>`;
}).join("");
const replyBadge = a.reply_type && a.reply_type !== 'new'
? `<span class="badge badge-comments" style="margin-left:0.5rem">${a.reply_type}</span>`
: '';
return `
<div class="approval-card status-${statusClass}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.25rem">
<div style="font-weight:600;font-size:0.95rem;flex:1;margin-right:0.5rem">${a.subject}</div>
<div style="font-weight:600;font-size:0.95rem;flex:1;margin-right:0.5rem">
${a.subject}
${replyBadge}
${isAgentDraft ? '<span class="badge badge-bot">bot draft</span>' : ''}
</div>
<span class="badge badge-${statusClass}">${a.status}</span>
</div>
<div class="approval-to">To: ${(a.to_addresses || []).map((e: string) => `<code>${e}</code>`).join(", ")}</div>
@ -577,6 +770,145 @@ class FolkInboxClient extends HTMLElement {
`;
}
private renderPersonal(): string {
const connectForm = this.connectFormOpen ? `
<div class="compose-panel">
<h3>&#128233; Connect Email Account</h3>
<div class="compose-field">
<label>Label</label>
<input id="pi-label" type="text" placeholder="Gmail, Work, etc." />
</div>
<div class="compose-field">
<label>Email Address</label>
<input id="pi-email" type="email" placeholder="you@example.com" />
</div>
<div class="compose-field">
<label>IMAP Host</label>
<input id="pi-imap-host" type="text" placeholder="imap.gmail.com" />
</div>
<div class="compose-field">
<label>IMAP Port</label>
<input id="pi-imap-port" type="number" value="993" />
</div>
<div class="compose-field">
<label>IMAP Username</label>
<input id="pi-imap-user" type="text" placeholder="you@example.com" />
</div>
<div class="compose-field">
<label>IMAP Password</label>
<input id="pi-imap-pass" type="password" placeholder="App password" />
</div>
<div class="compose-field">
<label>SMTP Host (optional, defaults to IMAP host)</label>
<input id="pi-smtp-host" type="text" placeholder="smtp.gmail.com" />
</div>
<div class="compose-field">
<label>SMTP Port</label>
<input id="pi-smtp-port" type="number" value="587" />
</div>
<div class="compose-actions">
<button class="btn-submit" data-action="submit-connect">Connect &amp; Test</button>
<button class="btn-cancel" data-action="cancel-connect">Cancel</button>
</div>
</div>
` : '';
const cards = this.personalInboxes.map((pi: any) => `
<div class="inbox-card">
<div style="font-size:1.5rem">&#128233;</div>
<div class="inbox-card-info">
<div class="inbox-card-label">${pi.label}</div>
<div class="inbox-card-email">${pi.email}</div>
<div class="inbox-card-status ${pi.status}">${pi.status}${pi.last_sync_at ? ` — synced ${this.timeAgo(pi.last_sync_at)}` : ''}</div>
</div>
<div class="inbox-card-actions">
<button data-disconnect="${pi.id}">Disconnect</button>
</div>
</div>
`).join('');
return `
<button class="btn-compose" data-action="connect-account">&#128233; Connect Account</button>
${connectForm}
${cards}
${!this.connectFormOpen && this.personalInboxes.length === 0 ? `
<div class="empty">
<p style="font-size:2rem;margin-bottom:1rem">&#128233;</p>
<p style="font-size:1rem;color:#e2e8f0;font-weight:500;margin-bottom:0.5rem">No personal inboxes</p>
<p style="font-size:0.8rem;color:#64748b;max-width:380px;margin:0 auto;line-height:1.5">
Connect your personal email accounts (Gmail, Outlook, etc.) to read and reply from rSpace.
</p>
</div>
` : ''}
`;
}
private renderAgents(): string {
const agentForm = this.agentFormOpen ? `
<div class="compose-panel">
<h3>&#129302; Create Agent Inbox</h3>
<div class="compose-field">
<label>Agent Name</label>
<input id="agent-name" type="text" placeholder="Support Bot, Triage Agent..." />
</div>
<div class="compose-field">
<label>Email Address</label>
<input id="agent-email" type="email" placeholder="support-bot@rspace.online" />
</div>
<div class="compose-field">
<label>Personality / System Prompt</label>
<textarea id="agent-personality" placeholder="You are a helpful support agent. Be concise and friendly..."></textarea>
</div>
<div style="display:flex;gap:1rem;margin-bottom:0.75rem">
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.8rem;color:#94a3b8;cursor:pointer">
<input type="checkbox" id="agent-auto-reply" /> Auto-reply
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.8rem;color:#94a3b8;cursor:pointer">
<input type="checkbox" id="agent-auto-classify" checked /> Auto-classify
</label>
</div>
<div class="compose-actions">
<button class="btn-submit" data-action="submit-agent">Create Agent</button>
<button class="btn-cancel" data-action="cancel-agent">Cancel</button>
</div>
</div>
` : '';
const cards = this.agentInboxes.map((ai: any) => `
<div class="inbox-card">
<div style="font-size:1.5rem">&#129302;</div>
<div class="inbox-card-info">
<div class="inbox-card-label">${ai.name}</div>
<div class="inbox-card-email">${ai.email}</div>
<div class="agent-toggle">
Auto-reply: <span class="${ai.auto_reply ? 'on' : 'off'}">${ai.auto_reply ? 'ON' : 'OFF'}</span>
&middot; Auto-classify: <span class="${ai.auto_classify ? 'on' : 'off'}">${ai.auto_classify ? 'ON' : 'OFF'}</span>
&middot; ${ai.rules?.length || 0} rule${(ai.rules?.length || 0) !== 1 ? 's' : ''}
</div>
</div>
<div class="inbox-card-actions">
<button data-delete-agent="${ai.id}">Delete</button>
</div>
</div>
`).join('');
return `
<button class="btn-compose" data-action="create-agent">&#129302; Create Agent</button>
${agentForm}
${cards}
${!this.agentFormOpen && this.agentInboxes.length === 0 ? `
<div class="empty">
<p style="font-size:2rem;margin-bottom:1rem">&#129302;</p>
<p style="font-size:1rem;color:#e2e8f0;font-weight:500;margin-bottom:0.5rem">No agent inboxes</p>
<p style="font-size:0.8rem;color:#64748b;max-width:380px;margin:0 auto;line-height:1.5">
Create AI-managed email agents that auto-classify, tag, and draft replies based on rules.
Agent-drafted replies still go through the approval workflow.
</p>
</div>
` : ''}
`;
}
private renderHelp(): string {
return `
<div class="help-overlay" data-action="close-help-overlay">
@ -624,7 +956,11 @@ class FolkInboxClient extends HTMLElement {
</div>
<div class="help-step">
<span class="help-step-num">3</span>
<span class="help-step-text"><strong>Approve &amp; send</strong> &mdash; draft a reply, then collect the required signatures. Once the threshold is met, the email sends.</span>
<span class="help-step-text"><strong>Reply, forward, or compose</strong> &mdash; draft a reply inline, forward to someone, or compose a new email. All go through approval.</span>
</div>
<div class="help-step">
<span class="help-step-num">4</span>
<span class="help-step-text"><strong>Approve &amp; send</strong> &mdash; collect the required signatures. Once the threshold is met, the email sends automatically via SMTP.</span>
</div>
</div>
</div>
@ -670,6 +1006,12 @@ class FolkInboxClient extends HTMLElement {
} else if (nav === "threads") {
this.view = "threads";
this.render();
} else if (nav === "personal") {
this.view = "personal";
this.loadPersonalInboxes();
} else if (nav === "agents") {
this.view = "agents";
this.loadAgentInboxes();
}
});
});
@ -680,8 +1022,9 @@ class FolkInboxClient extends HTMLElement {
backBtn.addEventListener("click", () => {
if (this.view === "thread") {
this.view = "threads";
this.composeOpen = false;
this.render();
} else if (this.view === "threads" || this.view === "approvals") {
} else if (this.view === "threads" || this.view === "approvals" || this.view === "personal" || this.view === "agents") {
this.view = "mailboxes";
this.currentMailbox = null;
this.render();
@ -721,6 +1064,7 @@ class FolkInboxClient extends HTMLElement {
row.addEventListener("click", () => {
const id = (row as HTMLElement).dataset.thread!;
this.view = "thread";
this.composeOpen = false;
this.loadThread(id);
});
});
@ -733,10 +1077,73 @@ class FolkInboxClient extends HTMLElement {
});
});
// Compose
// Thread actions: Reply / Reply All / Forward
this.shadow.querySelector("[data-action='reply']")?.addEventListener("click", () => {
this.composeMode = 'reply';
this.composeOpen = true;
this.render();
});
this.shadow.querySelector("[data-action='reply-all']")?.addEventListener("click", () => {
this.composeMode = 'reply-all';
this.composeOpen = true;
this.render();
});
this.shadow.querySelector("[data-action='forward']")?.addEventListener("click", () => {
this.composeMode = 'forward';
this.composeOpen = true;
this.render();
});
// Submit reply/forward from thread detail
this.shadow.querySelector("[data-action='submit-reply']")?.addEventListener("click", async () => {
if (this.space === "demo" || this.showingSampleData) {
alert("Reply is disabled in demo mode.");
return;
}
const t = this.currentThread;
if (!t) 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 (!subject || !body) { alert("Please fill subject and body."); return; }
const base = window.location.pathname.replace(/\/$/, "");
try {
if (this.composeMode === 'reply') {
await fetch(`${base}/api/threads/${t.id}/reply`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ body_text: body }),
});
} else if (this.composeMode === 'reply-all') {
await fetch(`${base}/api/threads/${t.id}/reply-all`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ body_text: body }),
});
} else if (this.composeMode === 'forward') {
if (!to) { alert("Forward requires a To address."); return; }
await fetch(`${base}/api/threads/${t.id}/forward`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ body_text: body, to_addresses: to.split(',').map(s => s.trim()).filter(Boolean) }),
});
}
this.composeOpen = false;
this.view = "approvals";
this.loadApprovals();
} catch { alert("Failed to submit. Please try again."); }
});
// Compose (from approvals view)
const composeBtn = this.shadow.querySelector("[data-action='compose']");
if (composeBtn) {
composeBtn.addEventListener("click", () => { this.composeOpen = !this.composeOpen; this.render(); });
composeBtn.addEventListener("click", () => {
this.composeMode = 'new';
this.composeOpen = !this.composeOpen;
this.render();
});
}
const cancelCompose = this.shadow.querySelector("[data-action='cancel-compose']");
if (cancelCompose) {
@ -758,8 +1165,8 @@ class FolkInboxClient extends HTMLElement {
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 }),
headers: this.getAuthHeaders(),
body: JSON.stringify({ to_addresses: [to], subject, body_text: body, mailbox_slug: mailbox }),
});
this.composeOpen = false;
this.loadApprovals();
@ -775,7 +1182,7 @@ class FolkInboxClient extends HTMLElement {
const base = window.location.pathname.replace(/\/$/, "");
await fetch(`${base}/api/approvals/${id}/sign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: this.getAuthHeaders(),
body: JSON.stringify({ vote: "APPROVE" }),
});
this.loadApprovals();
@ -788,12 +1195,96 @@ class FolkInboxClient extends HTMLElement {
const base = window.location.pathname.replace(/\/$/, "");
await fetch(`${base}/api/approvals/${id}/sign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: this.getAuthHeaders(),
body: JSON.stringify({ vote: "REJECT" }),
});
this.loadApprovals();
});
});
// Personal inbox actions
this.shadow.querySelector("[data-action='connect-account']")?.addEventListener("click", () => {
this.connectFormOpen = !this.connectFormOpen;
this.render();
});
this.shadow.querySelector("[data-action='cancel-connect']")?.addEventListener("click", () => {
this.connectFormOpen = false;
this.render();
});
this.shadow.querySelector("[data-action='submit-connect']")?.addEventListener("click", async () => {
if (this.space === "demo" || this.showingSampleData) { alert("Connect is disabled in demo mode."); return; }
const label = (this.shadow.getElementById("pi-label") as HTMLInputElement)?.value.trim();
const email = (this.shadow.getElementById("pi-email") as HTMLInputElement)?.value.trim();
const imapHost = (this.shadow.getElementById("pi-imap-host") as HTMLInputElement)?.value.trim();
const imapPort = parseInt((this.shadow.getElementById("pi-imap-port") as HTMLInputElement)?.value || "993");
const imapUser = (this.shadow.getElementById("pi-imap-user") as HTMLInputElement)?.value.trim();
const imapPass = (this.shadow.getElementById("pi-imap-pass") as HTMLInputElement)?.value;
const smtpHost = (this.shadow.getElementById("pi-smtp-host") as HTMLInputElement)?.value.trim();
const smtpPort = parseInt((this.shadow.getElementById("pi-smtp-port") as HTMLInputElement)?.value || "587");
if (!label || !email || !imapHost || !imapUser || !imapPass) { alert("Please fill required fields."); return; }
try {
const base = window.location.pathname.replace(/\/$/, "");
const resp = await fetch(`${base}/api/personal-inboxes?space=${this.space}`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ label, email, imap_host: imapHost, imap_port: imapPort, imap_user: imapUser, imap_pass: imapPass, smtp_host: smtpHost || undefined, smtp_port: smtpPort }),
});
if (!resp.ok) {
const err = await resp.json();
alert(err.error || "Failed to connect.");
return;
}
this.connectFormOpen = false;
this.loadPersonalInboxes();
} catch { alert("Failed to connect. Please try again."); }
});
this.shadow.querySelectorAll("[data-disconnect]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (this.space === "demo" || this.showingSampleData) { alert("Demo mode."); return; }
const id = (btn as HTMLElement).dataset.disconnect!;
const base = window.location.pathname.replace(/\/$/, "");
await fetch(`${base}/api/personal-inboxes/${id}`, { method: "DELETE", headers: this.getAuthHeaders() });
this.loadPersonalInboxes();
});
});
// Agent inbox actions
this.shadow.querySelector("[data-action='create-agent']")?.addEventListener("click", () => {
this.agentFormOpen = !this.agentFormOpen;
this.render();
});
this.shadow.querySelector("[data-action='cancel-agent']")?.addEventListener("click", () => {
this.agentFormOpen = false;
this.render();
});
this.shadow.querySelector("[data-action='submit-agent']")?.addEventListener("click", async () => {
if (this.space === "demo" || this.showingSampleData) { alert("Agent creation is disabled in demo mode."); return; }
const name = (this.shadow.getElementById("agent-name") as HTMLInputElement)?.value.trim();
const email = (this.shadow.getElementById("agent-email") as HTMLInputElement)?.value.trim();
const personality = (this.shadow.getElementById("agent-personality") as HTMLTextAreaElement)?.value.trim();
const autoReply = (this.shadow.getElementById("agent-auto-reply") as HTMLInputElement)?.checked;
const autoClassify = (this.shadow.getElementById("agent-auto-classify") as HTMLInputElement)?.checked;
if (!name || !email) { alert("Name and email required."); return; }
try {
const base = window.location.pathname.replace(/\/$/, "");
await fetch(`${base}/api/agent-inboxes?space=${this.space}`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({ name, email, personality, auto_reply: autoReply, auto_classify: autoClassify, rules: [] }),
});
this.agentFormOpen = false;
this.loadAgentInboxes();
} catch { alert("Failed to create agent."); }
});
this.shadow.querySelectorAll("[data-delete-agent]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (this.space === "demo" || this.showingSampleData) { alert("Demo mode."); return; }
const id = (btn as HTMLElement).dataset.deleteAgent!;
const base = window.location.pathname.replace(/\/$/, "");
await fetch(`${base}/api/agent-inboxes/${id}`, { method: "DELETE", headers: this.getAuthHeaders() });
this.loadAgentInboxes();
});
});
}
}

View File

@ -25,12 +25,43 @@ import {
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) */
@ -52,6 +83,15 @@ interface ImapConfig {
}
const _imapConfigs = new Map<string, ImapConfig>(); // 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<string, PersonalImapSmtpConfig>(); // personalInboxId → creds
/** IMAP sync state per mailbox (transient server state) */
interface ImapSyncState {
mailboxId: string;
@ -88,6 +128,8 @@ function ensureMailboxDoc(space: string, mailboxId: string): MailboxDoc {
d.members = [];
d.threads = {};
d.approvals = {};
d.personalInboxes = {};
d.agentInboxes = {};
});
_syncServer!.setDoc(docId, doc);
}
@ -207,6 +249,10 @@ function threadToRest(t: ThreadItem) {
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,
};
}
@ -242,9 +288,126 @@ function approvalToRest(a: ApprovalItem) {
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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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
@ -297,7 +460,7 @@ routes.post("/api/mailboxes", async (c) => {
d.meta = {
module: 'inbox',
collection: 'mailboxes',
version: 1,
version: 2,
spaceSlug: space,
createdAt: now,
};
@ -318,6 +481,8 @@ routes.post("/api/mailboxes", async (c) => {
d.members = [];
d.threads = {};
d.approvals = {};
d.personalInboxes = {};
d.agentInboxes = {};
});
_syncServer!.setDoc(mailboxDocId(space, mailboxId), doc);
@ -476,6 +641,168 @@ routes.post("/api/threads/:id/comments", async (c) => {
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<MailboxDoc>(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<MailboxDoc>(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<string>();
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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(docId)!;
return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201);
});
// ── Approvals API ──
// GET /api/approvals — list pending approvals
@ -505,6 +832,24 @@ routes.get("/api/approvals", async (c) => {
}
});
// 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);
@ -513,7 +858,7 @@ routes.post("/api/approvals", async (c) => {
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;
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);
@ -534,35 +879,21 @@ routes.post("/api/approvals", async (c) => {
bodyText: body_text || '',
bodyHtml: '',
toAddresses: to_addresses || [],
ccAddresses: [],
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 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);
const updatedDoc = _syncServer!.getDoc<MailboxDoc>(docId)!;
return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201);
});
// POST /api/approvals/:id/sign — sign an approval
@ -624,6 +955,11 @@ routes.post("/api/approvals/:id/sign", async (c) => {
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,
@ -632,6 +968,235 @@ routes.post("/api/approvals/:id/sign", async (c) => {
});
});
// ── 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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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
@ -684,7 +1249,7 @@ routes.post("/api/workspaces", async (c) => {
});
// GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true, imapSync: IMAP_HOST !== "" }));
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) => {
@ -809,6 +1374,8 @@ async function syncMailbox(mailbox: SyncableMailbox) {
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();
@ -841,9 +1408,16 @@ async function syncMailbox(mailbox: SyncableMailbox) {
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);
@ -873,6 +1447,76 @@ async function syncMailbox(mailbox: SyncableMailbox) {
}
}
/** Process agent inbox rules for a new inbound thread */
function processAgentRules(docId: string, threadId: string) {
const doc = _syncServer!.getDoc<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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<MailboxDoc>(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");
@ -999,17 +1643,19 @@ function seedTemplateInbox(space: string) {
const day = 86400000;
const doc = Automerge.change(Automerge.init<MailboxDoc>(), 'seed template mailbox', (d) => {
d.meta = { module: 'inbox', collection: 'mailboxes', version: 1, spaceSlug: space, createdAt: now };
d.meta = { module: 'inbox', collection: 'mailboxes', version: 2, 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.',
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 }> = [
{
@ -1037,17 +1683,18 @@ function seedTemplateInbox(space: string) {
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: [],
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, 3 threads`);
console.log(`[Inbox] Template seeded for "${space}": 1 mailbox (${space}-inbox), 3 threads`);
}
export const inboxModule: RSpaceModule = {
@ -1063,6 +1710,11 @@ export const inboxModule: RSpaceModule = {
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: [

View File

@ -48,6 +48,11 @@ export interface ThreadItem {
receivedAt: number;
createdAt: number;
comments: ThreadComment[];
// Threading (RFC 5322)
inReplyTo: string | null;
references: string[];
direction: 'inbound' | 'outbound';
parentThreadId: string | null;
}
export interface ApprovalSignature {
@ -74,6 +79,44 @@ export interface ApprovalItem {
createdAt: number;
resolvedAt: number;
signatures: ApprovalSignature[];
// Reply/forward context
inReplyTo: string | null;
references: string[];
replyType: 'reply' | 'reply-all' | 'forward' | 'new';
}
// ── Personal Inbox ──
export interface PersonalInbox {
id: string;
ownerDid: string;
label: string;
email: string;
imapHost: string;
imapPort: number;
smtpHost: string;
smtpPort: number;
lastSyncAt: number;
status: 'active' | 'error' | 'paused';
}
// ── Agent Inbox ──
export interface AgentRule {
match: { field: 'from' | 'subject' | 'body'; pattern: string };
action: 'reply' | 'forward' | 'tag' | 'assign';
value?: string;
}
export interface AgentInbox {
id: string;
spaceSlug: string;
name: string;
email: string;
personality: string;
autoReply: boolean;
autoClassify: boolean;
rules: AgentRule[];
}
export interface MailboxMeta {
@ -103,6 +146,8 @@ export interface MailboxDoc {
members: MailboxMember[];
threads: Record<string, ThreadItem>;
approvals: Record<string, ApprovalItem>;
personalInboxes: Record<string, PersonalInbox>;
agentInboxes: Record<string, AgentInbox>;
}
// ── Schema registration ──
@ -110,12 +155,12 @@ export interface MailboxDoc {
export const mailboxSchema: DocSchema<MailboxDoc> = {
module: 'inbox',
collection: 'mailboxes',
version: 1,
version: 2,
init: (): MailboxDoc => ({
meta: {
module: 'inbox',
collection: 'mailboxes',
version: 1,
version: 2,
spaceSlug: '',
createdAt: Date.now(),
},
@ -136,6 +181,8 @@ export const mailboxSchema: DocSchema<MailboxDoc> = {
members: [],
threads: {},
approvals: {},
personalInboxes: {},
agentInboxes: {},
}),
};

View File

@ -1732,7 +1732,8 @@
folk-freecad,
folk-kicad,
folk-zine-gen,
folk-rapp {
folk-rapp,
folk-multisig-email {
position: absolute;
}
@ -1753,7 +1754,7 @@
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp) {
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email) {
cursor: crosshair;
}
@ -1765,7 +1766,7 @@
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp):hover {
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email):hover {
outline: 2px dashed #3b82f6;
outline-offset: 4px;
}
@ -2252,6 +2253,7 @@
<button id="new-spider-3d" title="3D Spider">📊 3D Spider</button>
<button id="new-conviction" title="Conviction">⏳ Conviction</button>
<button id="new-token" title="Token">🪙 Token</button>
<button id="new-multisig-email" title="Multi-Sig Email">✉️ Multi-Sig Email</button>
<button id="embed-vote" title="Embed rVote">🗳️ rVote</button>
</div>
</div>
@ -2898,6 +2900,7 @@
"folk-splat", "folk-blender", "folk-drawfast",
"folk-freecad", "folk-kicad",
"folk-rapp",
"folk-multisig-email",
"folk-feed"
].join(", ");
@ -3650,6 +3653,21 @@
case "folk-kicad":
shape = document.createElement("folk-kicad");
break;
case "folk-multisig-email":
shape = document.createElement("folk-multisig-email");
if (data.mailboxSlug) shape.mailboxSlug = data.mailboxSlug;
if (data.toAddresses) shape.toAddresses = data.toAddresses;
if (data.ccAddresses) shape.ccAddresses = data.ccAddresses;
if (data.subject) shape.subject = data.subject;
if (data.bodyText) shape.bodyText = data.bodyText;
if (data.bodyHtml) shape.bodyHtml = data.bodyHtml;
if (data.replyToThreadId) shape.replyToThreadId = data.replyToThreadId;
if (data.replyType) shape.replyType = data.replyType;
if (data.approvalId) shape.approvalId = data.approvalId;
if (data.status) shape.status = data.status;
if (data.requiredSignatures != null) shape.requiredSignatures = data.requiredSignatures;
if (data.signatures) shape.signatures = data.signatures;
break;
case "folk-canvas":
shape = document.createElement("folk-canvas");
shape.parentSlug = communitySlug; // pass parent context for nest-from
@ -3789,6 +3807,7 @@
"folk-spider-3d": { width: 440, height: 480 },
"folk-choice-conviction": { width: 380, height: 480 },
"folk-social-post": { width: 300, height: 380 },
"folk-multisig-email": { width: 400, height: 380 },
"folk-splat": { width: 480, height: 420 },
"folk-blender": { width: 420, height: 520 },
"folk-drawfast": { width: 500, height: 480 },
@ -4349,6 +4368,19 @@
});
});
// Multi-sig email
document.getElementById("new-multisig-email").addEventListener("click", () => {
setPendingTool("folk-multisig-email", {
mailboxSlug: "",
toAddresses: [],
subject: "",
bodyText: "",
status: "draft",
requiredSignatures: 2,
signatures: [],
});
});
// Social media post
document.getElementById("new-social-post").addEventListener("click", () => {
setPendingTool("folk-social-post", {
@ -5181,7 +5213,7 @@
"folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱",
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
"folk-freecad": "📐", "folk-kicad": "🔌",
"folk-rapp": "📱", "folk-feed": "🔄", "folk-arrow": "↗️",
"folk-rapp": "📱", "folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️",
};
function getShapeLabel(data) {
@ -5923,7 +5955,7 @@
});
// ── Feed Mode ──
const FEED_SHAPE_SELECTOR = 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-token-mint, folk-token-ledger, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-feed';
const FEED_SHAPE_SELECTOR = 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-token-mint, folk-token-ledger, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-multisig-email, folk-feed';
let feedMode = false;
let feedSortKey = 'y';