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:
parent
de2a1baf84
commit
61b25e299f
|
|
@ -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">✉ 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">✉ 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' ? '✓' : ''}</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">✓ Approve</button>
|
||||
<button class="btn btn-danger" data-action="reject">✗ 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
private escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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">←</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">★</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">💬 ${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>·</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">★</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-body">${t.body_text || t.body_html || "(no content)"}</div>
|
||||
<div class="thread-actions">
|
||||
<button data-action="reply">↩ Reply</button>
|
||||
<button data-action="reply-all">↩↩ Reply All</button>
|
||||
<button data-action="forward">↪ Forward</button>
|
||||
</div>
|
||||
${composeHtml}
|
||||
<div class="comments-section">
|
||||
<div class="comments-title">💬 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>✎ ${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">
|
||||
🔒 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
private renderApprovals(): string {
|
||||
const composeForm = this.composeOpen ? `
|
||||
const composeForm = this.composeOpen && this.view === 'approvals' ? `
|
||||
<div class="compose-panel">
|
||||
<h3>✎ 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">
|
||||
🔒 This email requires <strong style="color:#818cf8">2 of 3</strong> team member signatures before it will be sent.
|
||||
🔒 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>📩 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 & 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">📩</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">📩 Connect Account</button>
|
||||
${connectForm}
|
||||
${cards}
|
||||
${!this.connectFormOpen && this.personalInboxes.length === 0 ? `
|
||||
<div class="empty">
|
||||
<p style="font-size:2rem;margin-bottom:1rem">📩</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>🤖 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">🤖</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>
|
||||
· Auto-classify: <span class="${ai.auto_classify ? 'on' : 'off'}">${ai.auto_classify ? 'ON' : 'OFF'}</span>
|
||||
· ${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">🤖 Create Agent</button>
|
||||
${agentForm}
|
||||
${cards}
|
||||
${!this.agentFormOpen && this.agentInboxes.length === 0 ? `
|
||||
<div class="empty">
|
||||
<p style="font-size:2rem;margin-bottom:1rem">🤖</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 & send</strong> — 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> — 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 & send</strong> — 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue