rspace-online/modules/rinbox/mod.ts

1018 lines
32 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,
} from './schemas';
let _syncServer: SyncServer | null = null;
const routes = new Hono();
// ── 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
/** 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 = {};
});
_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,
};
}
/** 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,
};
}
// ── 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 = "members_only", 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: 1,
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 = {};
});
_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);
});
// ── 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) });
}
});
// 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 } = 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: [],
status: 'PENDING',
requiredSignatures: doc.mailbox.approvalThreshold || 1,
safeTxHash: null,
createdAt: now,
resolvedAt: 0,
signatures: [],
};
});
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);
});
// 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;
return c.json({
ok: true,
status: finalApproval.status,
signatures: approveCount,
...(finalApproval.status === 'PENDING' ? { required: finalApproval.requiredSignatures } : {}),
});
});
// ── 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 !== "" }));
// 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 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: [],
};
});
count++;
}
} 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();
}
}
}
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">`,
}));
});
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,
async onInit(ctx) {
_syncServer = ctx.syncServer;
console.log("[Inbox] Module initialized (Automerge-only, no PG)");
},
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"],
};