1018 lines
32 KiB
TypeScript
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 & 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 — 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 & 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’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 — 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 & 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"],
|
|
};
|