rspace-online/modules/rinbox/mod.ts

1751 lines
56 KiB
TypeScript

/**
* Inbox module — collaborative email with multisig approval.
*
* Shared mailboxes with role-based access, threaded comments,
* and Gnosis Safe multisig approval for outgoing emails.
*
* Storage: Automerge documents via SyncServer (one doc per mailbox).
* IMAP credentials and sync state kept in module-scoped Maps (not in CRDT).
*/
import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
mailboxSchema,
mailboxDocId,
type MailboxDoc,
type MailboxMeta,
type ThreadItem,
type ThreadComment,
type ApprovalItem,
type ApprovalSignature,
type PersonalInbox,
type AgentInbox,
type AgentRule,
} from './schemas';
let _syncServer: SyncServer | null = null;
const routes = new Hono();
// ── SMTP Transport (lazy singleton) ──
let _smtpTransport: any = null;
const SMTP_HOST = process.env.SMTP_HOST || "mail.rmail.online";
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587");
const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASS = process.env.SMTP_PASS || "";
async function getSmtpTransport() {
if (_smtpTransport) return _smtpTransport;
try {
const nodemailer = await import("nodemailer");
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
_smtpTransport = createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
});
console.log(`[Inbox] SMTP transport configured: ${SMTP_HOST}:${SMTP_PORT}`);
return _smtpTransport;
} catch (e) {
console.error("[Inbox] Failed to create SMTP transport:", e);
return null;
}
}
// ── In-memory stores for data not in Automerge schemas ──
/** Workspace metadata (no Automerge schema — lightweight index) */
interface WorkspaceInfo {
id: string;
slug: string;
name: string;
description: string | null;
ownerDid: string;
createdAt: number;
}
const _workspaces = new Map<string, WorkspaceInfo>(); // slug → info
/** IMAP credentials per mailbox (not stored in CRDT for security) */
interface ImapConfig {
imapUser: string | null;
imapHost: string | null;
imapPort: number | null;
}
const _imapConfigs = new Map<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;
lastUid: number;
uidValidity: number | null;
lastSyncAt: number | null;
error: string | null;
}
const _syncStates = new Map<string, ImapSyncState>(); // mailboxId → state
// ── Helpers ──
/** Default space used when no space param is provided */
const DEFAULT_SPACE = "global";
function generateId(): string {
return crypto.randomUUID();
}
/**
* Ensure a mailbox Automerge doc exists for the given space + mailboxId.
* Returns the current doc state.
*/
function ensureMailboxDoc(space: string, mailboxId: string): MailboxDoc {
const docId = mailboxDocId(space, mailboxId);
let doc = _syncServer!.getDoc<MailboxDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<MailboxDoc>(), 'init mailbox', (d) => {
const init = mailboxSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.mailbox = init.mailbox;
d.mailbox.id = mailboxId;
d.members = [];
d.threads = {};
d.approvals = {};
d.personalInboxes = {};
d.agentInboxes = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/**
* Find a mailbox doc by slug across all docs on the SyncServer.
* Returns [space, mailboxId, doc] or null.
*/
function findMailboxBySlug(slug: string): [string, string, MailboxDoc] | null {
for (const id of _syncServer!.getDocIds()) {
// Match pattern: {space}:inbox:mailboxes:{mailboxId}
const parts = id.split(':');
if (parts.length === 4 && parts[1] === 'inbox' && parts[2] === 'mailboxes') {
const doc = _syncServer!.getDoc<MailboxDoc>(id);
if (doc && doc.mailbox && doc.mailbox.slug === slug) {
return [parts[0], parts[3], doc];
}
}
}
return null;
}
/**
* Find a mailbox doc by mailbox ID across all docs on the SyncServer.
* Returns [space, docId, doc] or null.
*/
function findMailboxById(mailboxId: string): [string, string, MailboxDoc] | null {
for (const id of _syncServer!.getDocIds()) {
const parts = id.split(':');
if (parts.length === 4 && parts[1] === 'inbox' && parts[2] === 'mailboxes' && parts[3] === mailboxId) {
const doc = _syncServer!.getDoc<MailboxDoc>(id);
if (doc) return [parts[0], id, doc];
}
}
return null;
}
/**
* Get all mailbox docs from the SyncServer.
*/
function getAllMailboxDocs(): Array<{ space: string; docId: string; doc: MailboxDoc }> {
const results: Array<{ space: string; docId: string; doc: MailboxDoc }> = [];
for (const id of _syncServer!.getDocIds()) {
const parts = id.split(':');
if (parts.length === 4 && parts[1] === 'inbox' && parts[2] === 'mailboxes') {
const doc = _syncServer!.getDoc<MailboxDoc>(id);
if (doc) results.push({ space: parts[0], docId: id, doc });
}
}
return results;
}
/**
* Find a thread by ID across all mailbox docs.
* Returns [docId, threadId, thread, doc] or null.
*/
function findThreadById(threadId: string): [string, string, ThreadItem, MailboxDoc] | null {
for (const { docId, doc } of getAllMailboxDocs()) {
const thread = doc.threads[threadId];
if (thread) return [docId, threadId, thread, doc];
}
return null;
}
/**
* Find an approval by ID across all mailbox docs.
* Returns [docId, approvalId, approval, doc] or null.
*/
function findApprovalById(approvalId: string): [string, string, ApprovalItem, MailboxDoc] | null {
for (const { docId, doc } of getAllMailboxDocs()) {
const approval = doc.approvals[approvalId];
if (approval) return [docId, approvalId, approval, doc];
}
return null;
}
/** Convert MailboxMeta to REST response format (snake_case) */
function mailboxToRest(mb: MailboxMeta) {
return {
id: mb.id,
workspace_id: mb.workspaceId,
slug: mb.slug,
name: mb.name,
email: mb.email,
description: mb.description,
visibility: mb.visibility,
owner_did: mb.ownerDid,
safe_address: mb.safeAddress,
safe_chain_id: mb.safeChainId,
approval_threshold: mb.approvalThreshold,
created_at: new Date(mb.createdAt).toISOString(),
imap_user: _imapConfigs.get(mb.id)?.imapUser || null,
};
}
/** Convert ThreadItem to REST response format */
function threadToRest(t: ThreadItem) {
return {
id: t.id,
mailbox_id: t.mailboxId,
message_id: t.messageId,
subject: t.subject,
from_address: t.fromAddress,
from_name: t.fromName,
to_addresses: t.toAddresses,
cc_addresses: t.ccAddresses,
body_text: t.bodyText,
body_html: t.bodyHtml,
tags: t.tags,
status: t.status,
is_read: t.isRead,
is_starred: t.isStarred,
assigned_to: t.assignedTo,
has_attachments: t.hasAttachments,
received_at: new Date(t.receivedAt).toISOString(),
created_at: new Date(t.createdAt).toISOString(),
comment_count: t.comments.length,
in_reply_to: t.inReplyTo || null,
references: t.references || [],
direction: t.direction || 'inbound',
parent_thread_id: t.parentThreadId || null,
};
}
/** Convert ThreadComment to REST response format */
function commentToRest(c: ThreadComment) {
return {
id: c.id,
thread_id: c.threadId,
author_id: c.authorId,
author_did: c.authorId, // In Automerge, authorId IS the DID
username: null as string | null,
body: c.body,
mentions: c.mentions,
created_at: new Date(c.createdAt).toISOString(),
};
}
/** Convert ApprovalItem to REST response format */
function approvalToRest(a: ApprovalItem) {
return {
id: a.id,
mailbox_id: a.mailboxId,
thread_id: a.threadId,
author_id: a.authorId,
subject: a.subject,
body_text: a.bodyText,
body_html: a.bodyHtml,
to_addresses: a.toAddresses,
cc_addresses: a.ccAddresses,
status: a.status,
required_signatures: a.requiredSignatures,
safe_tx_hash: a.safeTxHash,
created_at: new Date(a.createdAt).toISOString(),
resolved_at: a.resolvedAt ? new Date(a.resolvedAt).toISOString() : null,
signature_count: a.signatures.length,
in_reply_to: a.inReplyTo || null,
references: a.references || [],
reply_type: a.replyType || 'new',
};
}
/** Build a quoted message block for replies */
function quoteBlock(thread: ThreadItem): string {
const date = new Date(thread.receivedAt).toLocaleString();
const from = thread.fromName ? `${thread.fromName} <${thread.fromAddress}>` : (thread.fromAddress || 'Unknown');
const quotedBody = (thread.bodyText || '')
.split('\n')
.map(line => `> ${line}`)
.join('\n');
return `\nOn ${date}, ${from} wrote:\n${quotedBody}\n`;
}
/** Build forwarded message block */
function forwardBlock(thread: ThreadItem): string {
const date = new Date(thread.receivedAt).toLocaleString();
return [
'',
'---------- Forwarded message ----------',
`From: ${thread.fromName || ''} <${thread.fromAddress || ''}>`,
`Date: ${date}`,
`Subject: ${thread.subject}`,
`To: ${(thread.toAddresses || []).join(', ')}`,
'',
thread.bodyText || '',
].join('\n');
}
// ── Execute Approved Email ──
async function executeApproval(docId: string, approvalId: string) {
const transport = await getSmtpTransport();
if (!transport) {
console.error(`[Inbox] No SMTP transport — cannot send approval ${approvalId}`);
return;
}
const doc = _syncServer!.getDoc<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
routes.get("/api/mailboxes", async (c) => {
const { workspace } = c.req.query();
const allDocs = getAllMailboxDocs();
let mailboxes: ReturnType<typeof mailboxToRest>[];
if (workspace) {
const ws = _workspaces.get(workspace);
if (!ws) return c.json({ mailboxes: [] });
mailboxes = allDocs
.filter(({ doc }) => doc.mailbox.workspaceId === ws.id)
.map(({ doc }) => mailboxToRest(doc.mailbox))
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
} else {
mailboxes = allDocs
.map(({ doc }) => mailboxToRest(doc.mailbox))
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 50);
}
return c.json({ mailboxes });
});
// POST /api/mailboxes — create mailbox
routes.post("/api/mailboxes", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { slug, name, email, description, visibility = "private", imap_user, imap_password } = body;
if (!slug || !name || !email) return c.json({ error: "slug, name, email required" }, 400);
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400);
// Check uniqueness
const existing = findMailboxBySlug(slug);
if (existing) return c.json({ error: "Mailbox already exists" }, 409);
const space = (c.req.query("space") || c.req.param("space") || DEFAULT_SPACE);
const mailboxId = generateId();
const now = Date.now();
// Create Automerge doc
const doc = Automerge.change(Automerge.init<MailboxDoc>(), 'Create mailbox', (d) => {
d.meta = {
module: 'inbox',
collection: 'mailboxes',
version: 2,
spaceSlug: space,
createdAt: now,
};
d.mailbox = {
id: mailboxId,
workspaceId: null,
slug,
name,
email,
description: description || '',
visibility,
ownerDid: claims.sub,
safeAddress: null,
safeChainId: null,
approvalThreshold: 1,
createdAt: now,
};
d.members = [];
d.threads = {};
d.approvals = {};
d.personalInboxes = {};
d.agentInboxes = {};
});
_syncServer!.setDoc(mailboxDocId(space, mailboxId), doc);
// Store IMAP config separately
if (imap_user) {
_imapConfigs.set(mailboxId, { imapUser: imap_user, imapHost: null, imapPort: null });
}
return c.json(mailboxToRest(doc.mailbox), 201);
});
// GET /api/mailboxes/:slug — mailbox detail
routes.get("/api/mailboxes/:slug", async (c) => {
const slug = c.req.param("slug");
const found = findMailboxBySlug(slug);
if (!found) return c.json({ error: "Mailbox not found" }, 404);
const [, , doc] = found;
// Compute thread counts by status
const threadCounts: Record<string, number> = {};
for (const thread of Object.values(doc.threads)) {
threadCounts[thread.status] = (threadCounts[thread.status] || 0) + 1;
}
return c.json({ ...mailboxToRest(doc.mailbox), threadCounts });
});
// ── Threads API ──
// GET /api/mailboxes/:slug/threads — list threads
routes.get("/api/mailboxes/:slug/threads", async (c) => {
const slug = c.req.param("slug");
const { status, search, limit = "50", offset = "0" } = c.req.query();
const found = findMailboxBySlug(slug);
if (!found) return c.json({ error: "Mailbox not found" }, 404);
const [, , doc] = found;
let threads = Object.values(doc.threads);
// Filter by status
if (status) {
threads = threads.filter((t) => t.status === status);
}
// Filter by search (case-insensitive on subject and from_address)
if (search) {
const q = search.toLowerCase();
threads = threads.filter((t) =>
(t.subject && t.subject.toLowerCase().includes(q)) ||
(t.fromAddress && t.fromAddress.toLowerCase().includes(q))
);
}
// Sort by receivedAt descending
threads.sort((a, b) => b.receivedAt - a.receivedAt);
// Paginate
const lim = Math.min(parseInt(limit), 100);
const off = parseInt(offset) || 0;
const page = threads.slice(off, off + lim);
return c.json({ threads: page.map(threadToRest) });
});
// GET /api/threads/:id — thread detail with comments
routes.get("/api/threads/:id", async (c) => {
const id = c.req.param("id");
const found = findThreadById(id);
if (!found) return c.json({ error: "Thread not found" }, 404);
const [, , thread] = found;
const comments = [...thread.comments]
.sort((a, b) => a.createdAt - b.createdAt)
.map(commentToRest);
return c.json({ ...threadToRest(thread), comments });
});
// PATCH /api/threads/:id — update thread metadata
routes.patch("/api/threads/:id", async (c) => {
const id = c.req.param("id");
const body = await c.req.json();
const allowed = ["status", "is_read", "is_starred", "tags", "assigned_to"] as const;
// Check at least one valid field
const hasUpdate = allowed.some((key) => key in body);
if (!hasUpdate) return c.json({ error: "No valid fields" }, 400);
const found = findThreadById(id);
if (!found) return c.json({ error: "Thread not found" }, 404);
const [docId] = found;
const updated = _syncServer!.changeDoc<MailboxDoc>(docId, `Update thread ${id}`, (d) => {
const t = d.threads[id];
if (!t) return;
if ("status" in body) t.status = body.status;
if ("is_read" in body) t.isRead = body.is_read;
if ("is_starred" in body) t.isStarred = body.is_starred;
if ("tags" in body) {
// Replace tags array
t.tags.length = 0;
for (const tag of body.tags) t.tags.push(tag);
}
if ("assigned_to" in body) t.assignedTo = body.assigned_to;
});
if (!updated) return c.json({ error: "Thread not found" }, 404);
const thread = updated.threads[id];
return c.json(threadToRest(thread));
});
// POST /api/threads/:id/comments — add comment
routes.post("/api/threads/:id/comments", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const threadId = c.req.param("id");
const body = await c.req.json();
const { text, mentions } = body;
if (!text) return c.json({ error: "text required" }, 400);
const found = findThreadById(threadId);
if (!found) return c.json({ error: "Thread not found" }, 404);
const [docId] = found;
const commentId = generateId();
const now = Date.now();
_syncServer!.changeDoc<MailboxDoc>(docId, `Add comment to thread ${threadId}`, (d) => {
const t = d.threads[threadId];
if (!t) return;
t.comments.push({
id: commentId,
threadId,
authorId: claims.sub,
body: text,
mentions: mentions || [],
createdAt: now,
});
});
const comment: ThreadComment = {
id: commentId,
threadId,
authorId: claims.sub,
body: text,
mentions: mentions || [],
createdAt: now,
};
return c.json(commentToRest(comment), 201);
});
// ── Reply / Forward API ──
// POST /api/threads/:id/reply — create reply approval
routes.post("/api/threads/:id/reply", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const threadId = c.req.param("id");
const found = findThreadById(threadId);
if (!found) return c.json({ error: "Thread not found" }, 404);
const [docId, , thread, doc] = found;
const body = await c.req.json();
const approvalId = generateId();
const now = Date.now();
const subject = thread.subject.startsWith('Re:') ? thread.subject : `Re: ${thread.subject}`;
const toAddresses = thread.fromAddress ? [thread.fromAddress] : [];
const replyBody = (body.body_text || '') + quoteBlock(thread);
const references = [...(thread.references || [])];
if (thread.messageId) references.push(thread.messageId);
_syncServer!.changeDoc<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
routes.get("/api/approvals", async (c) => {
const { mailbox, status = "PENDING" } = c.req.query();
if (mailbox) {
const found = findMailboxBySlug(mailbox);
if (!found) return c.json({ error: "Mailbox not found" }, 404);
const [, , doc] = found;
const approvals = Object.values(doc.approvals)
.filter((a) => a.status === status)
.sort((a, b) => b.createdAt - a.createdAt)
.map(approvalToRest);
return c.json({ approvals });
} else {
const allApprovals: ReturnType<typeof approvalToRest>[] = [];
for (const { doc } of getAllMailboxDocs()) {
for (const a of Object.values(doc.approvals)) {
if (a.status === status) allApprovals.push(approvalToRest(a));
}
}
allApprovals.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return c.json({ approvals: allApprovals.slice(0, 50) });
}
});
// GET /api/approvals/:id — get single approval detail
routes.get("/api/approvals/:id", async (c) => {
const id = c.req.param("id");
const found = findApprovalById(id);
if (!found) return c.json({ error: "Approval not found" }, 404);
const [, , approval] = found;
return c.json({
...approvalToRest(approval),
signatures: approval.signatures.map(s => ({
id: s.id,
signer_id: s.signerId,
vote: s.vote,
signed_at: new Date(s.signedAt).toISOString(),
})),
});
});
// POST /api/approvals — create approval draft
routes.post("/api/approvals", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { mailbox_slug, thread_id, subject, body_text, to_addresses, cc_addresses, in_reply_to, references: refs, reply_type } = body;
if (!mailbox_slug || !subject) return c.json({ error: "mailbox_slug and subject required" }, 400);
const found = findMailboxBySlug(mailbox_slug);
if (!found) return c.json({ error: "Mailbox not found" }, 404);
const [space, mailboxId, doc] = found;
const docId = mailboxDocId(space, mailboxId);
const approvalId = generateId();
const now = Date.now();
_syncServer!.changeDoc<MailboxDoc>(docId, `Create approval draft`, (d) => {
d.approvals[approvalId] = {
id: approvalId,
mailboxId: doc.mailbox.id,
threadId: thread_id || null,
authorId: claims.sub,
subject,
bodyText: body_text || '',
bodyHtml: '',
toAddresses: to_addresses || [],
ccAddresses: cc_addresses || [],
status: 'PENDING',
requiredSignatures: doc.mailbox.approvalThreshold || 1,
safeTxHash: null,
createdAt: now,
resolvedAt: 0,
signatures: [],
inReplyTo: in_reply_to || null,
references: refs || [],
replyType: reply_type || 'new',
};
});
const updatedDoc = _syncServer!.getDoc<MailboxDoc>(docId)!;
return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201);
});
// POST /api/approvals/:id/sign — sign an approval
routes.post("/api/approvals/:id/sign", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const id = c.req.param("id");
const body = await c.req.json();
const { vote = "APPROVE" } = body;
if (!["APPROVE", "REJECT"].includes(vote)) return c.json({ error: "Invalid vote" }, 400);
const found = findApprovalById(id);
if (!found) return c.json({ error: "Approval not found" }, 404);
const [docId, , approval] = found;
if (approval.status !== "PENDING") return c.json({ error: "Approval not pending" }, 400);
const now = Date.now();
const updated = _syncServer!.changeDoc<MailboxDoc>(docId, `Sign approval ${id}`, (d) => {
const a = d.approvals[id];
if (!a) return;
// Upsert signature: replace if signer already voted
const existingIdx = a.signatures.findIndex((s) => s.signerId === claims.sub);
const sig: ApprovalSignature = {
id: existingIdx >= 0 ? a.signatures[existingIdx].id : generateId(),
approvalId: id,
signerId: claims.sub,
vote,
signedAt: now,
};
if (existingIdx >= 0) {
a.signatures[existingIdx].vote = vote;
a.signatures[existingIdx].signedAt = now;
} else {
a.signatures.push(sig);
}
// Count approvals and rejections
const approveCount = a.signatures.filter((s) => s.vote === 'APPROVE').length;
const rejectCount = a.signatures.filter((s) => s.vote === 'REJECT').length;
if (approveCount >= a.requiredSignatures) {
a.status = 'APPROVED';
a.resolvedAt = now;
} else if (rejectCount > 0) {
a.status = 'REJECTED';
a.resolvedAt = now;
}
});
if (!updated) return c.json({ error: "Approval not found" }, 404);
const finalApproval = updated.approvals[id];
const approveCount = finalApproval.signatures.filter((s) => s.vote === 'APPROVE').length;
// If approved, fire async send
if (finalApproval.status === 'APPROVED') {
executeApproval(docId, id).catch(e => console.error('[Inbox] executeApproval error:', e));
}
return c.json({
ok: true,
status: finalApproval.status,
signatures: approveCount,
...(finalApproval.status === 'PENDING' ? { required: finalApproval.requiredSignatures } : {}),
});
});
// ── Personal Inboxes API ──
// POST /api/personal-inboxes — connect a personal email account
routes.post("/api/personal-inboxes", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { label, email, imap_host, imap_port, imap_user, imap_pass, smtp_host, smtp_port, smtp_user, smtp_pass } = body;
if (!label || !email || !imap_host || !imap_user || !imap_pass) {
return c.json({ error: "label, email, imap_host, imap_user, imap_pass required" }, 400);
}
// Validate IMAP connection
try {
const { ImapFlow } = await import("imapflow");
const client = new ImapFlow({
host: imap_host,
port: imap_port || 993,
secure: (imap_port || 993) === 993,
auth: { user: imap_user, pass: imap_pass },
logger: false,
});
await client.connect();
await client.logout();
} catch (e: any) {
return c.json({ error: `IMAP connection failed: ${e.message}` }, 400);
}
const inboxId = generateId();
const now = Date.now();
// Store credentials securely (not in Automerge)
_personalCredentials.set(inboxId, {
imapUser: imap_user,
imapPass: imap_pass,
smtpUser: smtp_user || imap_user,
smtpPass: smtp_pass || imap_pass,
});
// Store metadata in first available mailbox doc for this space
const space = c.req.query("space") || c.req.param("space") || DEFAULT_SPACE;
const allDocs = getAllMailboxDocs().filter(d => d.space === space);
if (allDocs.length === 0) return c.json({ error: "No mailbox in this space" }, 404);
const { docId } = allDocs[0];
_syncServer!.changeDoc<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
routes.get("/api/workspaces", async (c) => {
const workspaces = Array.from(_workspaces.values())
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 50)
.map((ws) => ({
id: ws.id,
slug: ws.slug,
name: ws.name,
description: ws.description,
owner_did: ws.ownerDid,
created_at: new Date(ws.createdAt).toISOString(),
}));
return c.json({ workspaces });
});
// POST /api/workspaces
routes.post("/api/workspaces", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { slug, name, description } = body;
if (!slug || !name) return c.json({ error: "slug and name required" }, 400);
if (_workspaces.has(slug)) return c.json({ error: "Workspace already exists" }, 409);
const ws: WorkspaceInfo = {
id: generateId(),
slug,
name,
description: description || null,
ownerDid: claims.sub,
createdAt: Date.now(),
};
_workspaces.set(slug, ws);
return c.json({
id: ws.id,
slug: ws.slug,
name: ws.name,
description: ws.description,
owner_did: ws.ownerDid,
created_at: new Date(ws.createdAt).toISOString(),
}, 201);
});
// GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true, imapSync: IMAP_HOST !== "", smtpReady: !!SMTP_USER }));
// GET /api/sync-status — show IMAP sync state per mailbox
routes.get("/api/sync-status", async (c) => {
const syncStates: any[] = [];
for (const [mailboxId, state] of _syncStates) {
const found = findMailboxById(mailboxId);
if (!found) continue;
const [, , doc] = found;
syncStates.push({
mailbox_id: mailboxId,
last_uid: state.lastUid,
uid_validity: state.uidValidity,
last_sync_at: state.lastSyncAt ? new Date(state.lastSyncAt).toISOString() : null,
error: state.error,
slug: doc.mailbox.slug,
name: doc.mailbox.name,
email: doc.mailbox.email,
});
}
syncStates.sort((a, b) => {
if (!a.last_sync_at && !b.last_sync_at) return 0;
if (!a.last_sync_at) return 1;
if (!b.last_sync_at) return -1;
return new Date(b.last_sync_at).getTime() - new Date(a.last_sync_at).getTime();
});
return c.json({ syncStates });
});
// ── IMAP Sync Worker ──
const IMAP_HOST = process.env.IMAP_HOST || "";
const IMAP_PORT = parseInt(process.env.IMAP_PORT || "993");
const IMAP_TLS_REJECT = process.env.IMAP_TLS_REJECT_UNAUTHORIZED !== "false";
const SYNC_INTERVAL = 30_000; // 30 seconds
interface SyncableMailbox {
id: string;
slug: string;
email: string;
space: string;
imap_user: string | null;
imap_host: string | null;
imap_port: number | null;
}
async function syncMailbox(mailbox: SyncableMailbox) {
let ImapFlow: any;
let simpleParser: any;
try {
ImapFlow = (await import("imapflow")).ImapFlow;
simpleParser = (await import("mailparser")).simpleParser;
} catch {
console.error("[Inbox] imapflow/mailparser not installed — skipping IMAP sync");
return;
}
const host = mailbox.imap_host || IMAP_HOST;
const port = mailbox.imap_port || IMAP_PORT;
const user = mailbox.imap_user;
if (!host || !user) return;
// Get or create sync state
let syncState = _syncStates.get(mailbox.id);
if (!syncState) {
syncState = {
mailboxId: mailbox.id,
lastUid: 0,
uidValidity: null,
lastSyncAt: null,
error: null,
};
_syncStates.set(mailbox.id, syncState);
}
const client = new ImapFlow({
host,
port,
secure: port === 993,
auth: { user, pass: process.env[`IMAP_PASS_${mailbox.slug.toUpperCase().replace(/-/g, "_")}`] || "" },
tls: { rejectUnauthorized: IMAP_TLS_REJECT },
logger: false,
});
try {
await client.connect();
const lock = await client.getMailboxLock("INBOX");
try {
const status = client.mailbox;
const uidValidity = status?.uidValidity;
// UID validity changed — need full resync
if (syncState.uidValidity && uidValidity && syncState.uidValidity !== uidValidity) {
syncState.lastUid = 0;
syncState.uidValidity = uidValidity;
}
// Fetch messages newer than last synced UID
const lastUid = syncState.lastUid || 0;
const range = lastUid > 0 ? `${lastUid + 1}:*` : "1:*";
let maxUid = lastUid;
let count = 0;
const docId = mailboxDocId(mailbox.space, mailbox.id);
for await (const msg of client.fetch(range, {
envelope: true,
source: true,
uid: true,
})) {
if (msg.uid <= lastUid) continue;
if (count >= 100) break; // Batch limit
try {
const parsed = await simpleParser(msg.source);
const fromAddr = parsed.from?.value?.[0]?.address || msg.envelope?.from?.[0]?.address || "";
const fromName = parsed.from?.value?.[0]?.name || msg.envelope?.from?.[0]?.name || "";
const subject = parsed.subject || msg.envelope?.subject || "(no subject)";
const toAddrs = (parsed.to?.value || []).map((a: any) => a.address || "");
const ccAddrs = (parsed.cc?.value || []).map((a: any) => a.address || "");
const messageId = parsed.messageId || msg.envelope?.messageId || null;
const inReplyTo = parsed.inReplyTo || null;
const references = parsed.references ? (Array.isArray(parsed.references) ? parsed.references : [parsed.references]) : [];
const hasAttachments = (parsed.attachments?.length || 0) > 0;
const receivedAt = parsed.date ? parsed.date.getTime() : Date.now();
const threadId = generateId();
// Check for duplicate messageId before inserting
const currentDoc = _syncServer!.getDoc<MailboxDoc>(docId);
const isDuplicate = messageId && currentDoc &&
Object.values(currentDoc.threads).some((t) => t.messageId === messageId);
if (!isDuplicate) {
_syncServer!.changeDoc<MailboxDoc>(docId, `IMAP sync: ${subject}`, (d) => {
d.threads[threadId] = {
id: threadId,
mailboxId: mailbox.id,
messageId,
subject,
fromAddress: fromAddr,
fromName: fromName,
toAddresses: toAddrs,
ccAddresses: ccAddrs,
bodyText: parsed.text || '',
bodyHtml: parsed.html || '',
tags: [],
status: 'open',
isRead: false,
isStarred: false,
assignedTo: null,
hasAttachments,
receivedAt,
createdAt: Date.now(),
comments: [],
inReplyTo: inReplyTo || null,
references,
direction: 'inbound',
parentThreadId: null,
};
});
count++;
// Agent inbox processing
processAgentRules(docId, threadId);
}
} catch (parseErr) {
console.error(`[Inbox] Parse error UID ${msg.uid}:`, parseErr);
}
if (msg.uid > maxUid) maxUid = msg.uid;
}
// Update sync state
syncState.lastUid = maxUid;
syncState.uidValidity = uidValidity || null;
syncState.lastSyncAt = Date.now();
syncState.error = null;
if (count > 0) console.log(`[Inbox] Synced ${count} messages for ${mailbox.email}`);
} finally {
lock.release();
}
await client.logout();
} catch (e: any) {
console.error(`[Inbox] IMAP sync error for ${mailbox.email}:`, e.message);
if (syncState) {
syncState.error = e.message;
syncState.lastSyncAt = Date.now();
}
}
}
/** Process agent inbox rules for a new inbound thread */
function processAgentRules(docId: string, threadId: string) {
const doc = _syncServer!.getDoc<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");
return;
}
console.log(`[Inbox] IMAP sync enabled — polling every ${SYNC_INTERVAL / 1000}s`);
const doSync = async () => {
if (!_syncServer) return;
try {
// Find all mailboxes with IMAP config
const mailboxes: SyncableMailbox[] = [];
for (const { space, doc } of getAllMailboxDocs()) {
const imapCfg = _imapConfigs.get(doc.mailbox.id);
if (imapCfg?.imapUser) {
mailboxes.push({
id: doc.mailbox.id,
slug: doc.mailbox.slug,
email: doc.mailbox.email,
space,
imap_user: imapCfg.imapUser,
imap_host: imapCfg.imapHost,
imap_port: imapCfg.imapPort,
});
}
}
for (const mb of mailboxes) {
await syncMailbox(mb);
}
} catch (e) {
console.error("[Inbox] Sync loop error:", e);
}
};
// Initial sync after 5s delay
setTimeout(doSync, 5000);
// Recurring sync
setInterval(doSync, SYNC_INTERVAL);
}
// Start IMAP sync in background
runSyncLoop();
// ── About / use-cases landing ──
routes.get("/about", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Multi-Sig Inbox — rInbox | rSpace`,
moduleId: "rinbox",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `
<div style="max-width:56rem;margin:0 auto;padding:2rem 1.5rem;">
<h1 style="font-size:2rem;font-weight:bold;text-align:center;margin-bottom:0.5rem;">What can you do with a multi-sig inbox?</h1>
<p style="color:#94a3b8;text-align:center;margin-bottom:2.5rem;max-width:36rem;margin-left:auto;margin-right:auto;">
When outbound email requires collective approval, entirely new coordination patterns become possible.
</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem;">
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#22d3ee;">Governance &amp; Resolutions</h3>
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Board decisions require 3-of-5 signers before the email sends. The message is the vote &mdash; no separate tooling needed.</p>
</div>
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#a78bfa;">Escrow &amp; Conditional Release</h3>
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Hold sensitive documents in an inbox that only unlocks when N parties agree. Mediation where neither side can act alone.</p>
</div>
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#f87171;">Whistleblower Coordination</h3>
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Evidence requires M-of-N co-signers before release. Dead man&rsquo;s switch if a signer goes silent. Nobody goes first alone.</p>
</div>
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#4ade80;">Social Key Recovery</h3>
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Lost access? 3-of-5 trusted contacts co-sign your restoration. No phone number, no backup email &mdash; a trust network.</p>
</div>
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#fbbf24;">Tamper-Proof Audit Trails</h3>
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Every email read and sent is co-signed. Cryptographic proof of who approved what, when. Built for compliance.</p>
</div>
<div style="background:rgba(30,41,59,.5);border:1px solid rgba(51,65,85,.5);border-radius:1rem;padding:1.5rem;">
<h3 style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:#60a5fa;">Treasury &amp; Payments</h3>
<p style="color:#94a3b8;font-size:.875rem;line-height:1.6;">Invoice arrives, 2-of-3 finance team co-sign the reply authorizing payment. Bridges email to on-chain wallets.</p>
</div>
</div>
<div style="text-align:center;margin-top:2.5rem;">
<a href="/${space}/rinbox" style="display:inline-block;padding:0.75rem 2rem;background:#0891b2;color:white;border-radius:0.75rem;text-decoration:none;font-weight:600;">Open Inbox</a>
<a href="https://rinbox.online" style="display:inline-block;padding:0.75rem 2rem;margin-left:1rem;border:1px solid rgba(51,65,85,.5);color:#94a3b8;border-radius:0.75rem;text-decoration:none;font-weight:600;">rinbox.online</a>
</div>
</div>`,
scripts: "",
styles: "",
}));
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Inbox | rSpace`,
moduleId: "rinbox",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-inbox-client space="${space}"></folk-inbox-client>`,
scripts: `<script type="module" src="/modules/rinbox/folk-inbox-client.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rinbox/inbox.css">`,
}));
});
// ── Seed template data ──
function seedTemplateInbox(space: string) {
if (!_syncServer) return;
// Skip if space already has mailboxes
const prefix = `${space}:inbox:mailboxes:`;
const existing = _syncServer.listDocs().filter((id) => id.startsWith(prefix));
if (existing.length > 0) return;
const mbId = crypto.randomUUID();
const docId = mailboxDocId(space, mbId);
const now = Date.now();
const day = 86400000;
const doc = Automerge.change(Automerge.init<MailboxDoc>(), 'seed template mailbox', (d) => {
d.meta = { module: 'inbox', collection: 'mailboxes', version: 2, spaceSlug: space, createdAt: now };
d.mailbox = {
id: mbId, workspaceId: null, slug: `${space}-inbox`, name: `${space.charAt(0).toUpperCase() + space.slice(1)} Inbox`,
email: `${space}@rspace.online`,
description: `Shared mailbox for the ${space} space.`,
visibility: 'members', ownerDid: 'did:demo:seed',
safeAddress: null, safeChainId: null, approvalThreshold: 1, createdAt: now,
};
d.members = [];
d.threads = {};
d.approvals = {};
d.personalInboxes = {};
d.agentInboxes = {};
const threads: Array<{ subj: string; from: string; fromName: string; body: string; tags: string[]; age: number }> = [
{
subj: 'Grant Application Update — Gitcoin GG22',
from: 'grants@gitcoin.co', fromName: 'Gitcoin Grants',
body: 'Your application for the Cosmolocal Commons project has been accepted into GG22 Climate Solutions round. Matching pool opens April 1.',
tags: ['grants', 'important'], age: 2,
},
{
subj: 'Partnership Inquiry — Barcelona Maker Space',
from: 'hello@bcnmakers.cat', fromName: 'BCN Makers',
body: 'Hi! We saw your cosmolocal print network and would love to join as a provider. We have risograph, screen print, and laser cutting capabilities.',
tags: ['partnerships'], age: 5,
},
{
subj: 'Weekly Digest — rSpace Dev Updates',
from: 'digest@rspace.online', fromName: 'rSpace Bot',
body: '## This Week\n\n- 3 new modules deployed (rChoices, rSplat, rInbox)\n- EncryptID guardian recovery shipped\n- 2 new providers joined the cosmolocal network',
tags: ['digest'], age: 1,
},
];
for (const t of threads) {
const tId = crypto.randomUUID();
d.threads[tId] = {
id: tId, mailboxId: mbId, messageId: `<${crypto.randomUUID()}@demo>`,
subject: t.subj, fromAddress: t.from, fromName: t.fromName,
toAddresses: [`${space}@rspace.online`], ccAddresses: [],
bodyText: t.body, bodyHtml: '', tags: t.tags, status: 'open',
isRead: t.age > 3, isStarred: t.tags.includes('important'),
assignedTo: null, hasAttachments: false,
receivedAt: now - t.age * day, createdAt: now - t.age * day, comments: [],
inReplyTo: null, references: [], direction: 'inbound', parentThreadId: null,
};
}
});
_syncServer.setDoc(docId, doc);
console.log(`[Inbox] Template seeded for "${space}": 1 mailbox (${space}-inbox), 3 threads`);
}
export const inboxModule: RSpaceModule = {
id: "rinbox",
name: "rInbox",
icon: "\u{1F4E8}",
description: "Collaborative email with multisig approval",
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:inbox:mailboxes:{mailboxId}', description: 'Mailbox with threads and approvals', init: mailboxSchema.init }],
routes,
landingPage: renderLanding,
seedTemplate: seedTemplateInbox,
async onInit(ctx) {
_syncServer = ctx.syncServer;
console.log("[Inbox] Module initialized (Automerge-only, no PG)");
// Pre-warm SMTP transport
if (SMTP_USER) getSmtpTransport().catch(() => {});
},
async onSpaceCreate(ctx) {
seedTemplateInbox(ctx.spaceSlug);
},
standaloneDomain: "rinbox.online",
feeds: [
{
id: "messages",
name: "Messages",
kind: "data",
description: "Email threads and messages across shared mailboxes",
filterable: true,
},
{
id: "notifications",
name: "Notifications",
kind: "attention",
description: "New mail alerts, approval requests, and mention notifications",
},
],
acceptsFeeds: ["data"],
subPageInfos: [
{
path: "about",
title: "About rInbox",
icon: "📮",
tagline: "rInbox",
description: "Collaborative email for communities — shared mailboxes with multisig approval, threaded discussions, and team workflows.",
features: [
{ icon: "📬", title: "Shared Mailboxes", text: "Create shared inboxes that multiple team members can read and respond from." },
{ icon: "✅", title: "Multisig Approval", text: "Require multiple approvals before sending sensitive emails on behalf of the group." },
{ icon: "💬", title: "Internal Comments", text: "Discuss emails privately with your team before crafting a response." },
],
},
],
};