/**
 * 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 { verifyToken, extractToken } from "../../server/auth";
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 ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";

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;
		const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
		_smtpTransport = createTransport({
			host: SMTP_HOST,
			port: isInternal ? 25 : SMTP_PORT,
			secure: !isInternal && SMTP_PORT === 465,
			...(isInternal ? {} : SMTP_USER ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}),
			tls: { rejectUnauthorized: false },
		});
		console.log(`[Inbox] SMTP transport configured: ${SMTP_HOST}:${isInternal ? 25 : 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,
		content_format: c.contentFormat || null,
		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',
		approval_type: a.approvalType || 'email',
		listmonk_campaign_id: a.listmonkCampaignId ?? null,
	};
}

/** 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');
}

// ── Newsletter Approval Bridge ──

export function createNewsletterApproval(params: {
	space: string;
	authorId: string;
	subject: string;
	bodyText: string;
	bodyHtml: string;
	listmonkCampaignId: number;
}): { id: string; status: string; requiredSignatures: number } | null {
	if (!_syncServer) return null;
	const result = findMailboxBySlug(params.space);
	if (!result) return null;

	const [space, mailboxId, doc] = result;
	const docId = mailboxDocId(space, mailboxId);
	const approvalId = generateId();
	const threshold = doc.mailbox.approvalThreshold || 1;

	_syncServer.changeDoc<MailboxDoc>(docId, `newsletter approval for campaign ${params.listmonkCampaignId}`, (d) => {
		d.approvals[approvalId] = {
			id: approvalId,
			mailboxId: d.mailbox.id,
			threadId: null,
			authorId: params.authorId,
			subject: params.subject,
			bodyText: params.bodyText,
			bodyHtml: params.bodyHtml,
			toAddresses: [],
			ccAddresses: [],
			status: 'PENDING',
			requiredSignatures: threshold,
			safeTxHash: null,
			createdAt: Date.now(),
			resolvedAt: 0,
			signatures: [],
			inReplyTo: null,
			references: [],
			replyType: 'new',
			approvalType: 'newsletter',
			listmonkCampaignId: params.listmonkCampaignId,
		} as any;
	});

	return { id: approvalId, status: 'PENDING', requiredSignatures: threshold };
}

async function executeNewsletterApproval(docId: string, approvalId: string, approval: ApprovalItem) {
	const space = docId.split(':')[0];
	try {
		const { getListmonkConfig, startListmonkCampaign } = await import('../rsocials/lib/listmonk-proxy');
		const config = await getListmonkConfig(space);
		if (!config) throw new Error('Listmonk not configured for space ' + space);

		const result = await startListmonkCampaign(config, approval.listmonkCampaignId!);
		if (!result.ok) throw new Error(result.error || 'Listmonk API error');

		_syncServer!.changeDoc<MailboxDoc>(docId, `Newsletter sent: campaign ${approval.listmonkCampaignId}`, (d) => {
			const a = d.approvals[approvalId];
			if (!a || a.status !== 'APPROVED') return;
			a.status = 'SENT';

			// Audit trail thread
			const threadId = generateId();
			d.threads[threadId] = {
				id: threadId,
				mailboxId: d.mailbox.id,
				messageId: null,
				subject: a.subject,
				fromAddress: d.mailbox.email,
				fromName: d.mailbox.name,
				toAddresses: [],
				ccAddresses: [],
				bodyText: a.bodyText,
				bodyHtml: a.bodyHtml,
				tags: ['newsletter', 'sent'],
				status: 'closed',
				isRead: true,
				isStarred: false,
				assignedTo: null,
				hasAttachments: false,
				receivedAt: Date.now(),
				createdAt: Date.now(),
				comments: [],
				inReplyTo: null,
				references: [],
				direction: 'outbound',
				parentThreadId: null,
			};
		});

		console.log(`[Inbox] Newsletter campaign ${approval.listmonkCampaignId} started via approval ${approvalId}`);
	} catch (e: any) {
		console.error(`[Inbox] Newsletter send failed for approval ${approvalId}:`, e.message);
		_syncServer!.changeDoc<MailboxDoc>(docId, `Newsletter send error: ${approvalId}`, (d) => {
			const a = d.approvals[approvalId];
			if (a) a.status = 'SEND_ERROR';
		});
	}
}

// ── Execute Approved Email ──

async function executeApproval(docId: string, approvalId: string) {
	const doc = _syncServer!.getDoc<MailboxDoc>(docId);
	if (!doc) return;

	const approval = doc.approvals[approvalId];
	if (!approval || approval.status !== 'APPROVED') return;

	// Newsletter approvals → Listmonk, not SMTP
	if (approval.approvalType === 'newsletter') {
		return executeNewsletterApproval(docId, approvalId, approval);
	}

	const transport = await getSmtpTransport();
	if (!transport) {
		console.error(`[Inbox] No SMTP transport — cannot send approval ${approvalId}`);
		return;
	}

	// Find the mailbox to get the from address
	const mailboxEmail = doc.mailbox.email;
	const spaceSlug = docId.split(':')[0];
	const agentReplyTo = `${spaceSlug}-agent@rspace.online`;

	try {
		const mailOptions: any = {
			from: mailboxEmail,
			to: approval.toAddresses.join(', '),
			replyTo: agentReplyTo,
			subject: approval.subject,
			text: approval.bodyText,
			// Envelope override: authenticate as SMTP_USER but show mailboxEmail in From header
			envelope: { from: SMTP_USER || 'noreply@rmail.online', to: approval.toAddresses },
		};

		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 verifyToken(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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const threadId = c.req.param("id");
	const body = await c.req.json();
	const { text, mentions, content_format } = 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();
	const fmt = content_format === 'tiptap-json' ? 'tiptap-json' : undefined;

	_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,
			contentFormat: fmt,
			mentions: mentions || [],
			createdAt: now,
		});
	});

	const comment: ThreadComment = {
		id: commentId,
		threadId,
		authorId: claims.sub,
		body: text,
		contentFormat: fmt,
		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 verifyToken(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 verifyToken(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 verifyToken(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 verifyToken(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 verifyToken(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 verifyToken(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,
		});
		client.on('error', () => {}); // swallow — validation only
		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 verifyToken(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 verifyToken(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 verifyToken(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 verifyToken(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 verifyToken(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,
	});
	client.on('error', (err: Error) => {
		console.error(`[Inbox] IMAP client error for ${mailbox.email}:`, err.message);
	});

	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 */ }
		}
	}
}

// ── Per-Space Agent Mailbox Sync ──

/** Cached agent IMAP credentials, refreshed each sync cycle */
interface AgentImapCreds { spaceSlug: string; email: string; password: string }
let _agentCreds: AgentImapCreds[] = [];
let _agentCredsLastFetch = 0;
const AGENT_CREDS_TTL = 60_000; // refresh creds list every 60s

/** Fetch agent mailbox credentials from EncryptID (cached) */
async function getAgentCredentials(): Promise<AgentImapCreds[]> {
	if (Date.now() - _agentCredsLastFetch < AGENT_CREDS_TTL) return _agentCreds;
	try {
		const res = await fetch(`${ENCRYPTID_INTERNAL}/api/internal/agent-mailboxes`);
		if (!res.ok) return _agentCreds;
		const { mailboxes } = await res.json() as { mailboxes: AgentImapCreds[] };
		_agentCreds = mailboxes.filter((m) => m.password); // skip entries without password
		_agentCredsLastFetch = Date.now();
	} catch { /* keep cached */ }
	return _agentCreds;
}

/** Find the agent mailbox doc for a given space slug */
function findAgentMailboxDoc(spaceSlug: string): { docId: string; doc: MailboxDoc } | null {
	if (!_syncServer) return null;
	const prefix = `${spaceSlug}:inbox:mailboxes:`;
	const docs = _syncServer.listDocs().filter((id) => id.startsWith(prefix));
	for (const docId of docs) {
		const doc = _syncServer.getDoc<MailboxDoc>(docId);
		if (doc && doc.mailbox.slug === `${spaceSlug}-agent`) {
			return { docId, doc };
		}
	}
	return null;
}

/**
 * Sync a single agent mailbox via IMAP.
 * Each {space}-agent@rspace.online has its own real Mailcow mailbox.
 */
async function syncAgentMailbox(creds: AgentImapCreds) {
	if (!_syncServer || !IMAP_HOST) return;

	const agentDoc = findAgentMailboxDoc(creds.spaceSlug);
	if (!agentDoc) return; // no Automerge doc yet

	let ImapFlow: any;
	let simpleParser: any;
	try {
		ImapFlow = (await import("imapflow")).ImapFlow;
		simpleParser = (await import("mailparser")).simpleParser;
	} catch { return; }

	// Get or create sync state for this agent mailbox
	const syncKey = `agent:${creds.spaceSlug}`;
	let syncState = _syncStates.get(syncKey);
	if (!syncState) {
		syncState = { mailboxId: syncKey, lastUid: 0, uidValidity: null, lastSyncAt: null, error: null };
		_syncStates.set(syncKey, syncState);
	}

	const client = new ImapFlow({
		host: IMAP_HOST,
		port: IMAP_PORT,
		secure: IMAP_PORT === 993,
		auth: { user: creds.email, pass: creds.password },
		tls: { rejectUnauthorized: IMAP_TLS_REJECT },
		logger: false,
	});
	client.on('error', (err: Error) => {
		console.error(`[Inbox] Agent IMAP client error (${creds.email}):`, err.message);
	});

	try {
		await client.connect();
		const lock = await client.getMailboxLock("INBOX");

		try {
			const status = client.mailbox;
			const uidValidity = status?.uidValidity;

			if (syncState.uidValidity && uidValidity && syncState.uidValidity !== uidValidity) {
				syncState.lastUid = 0;
			}

			const range = syncState.lastUid > 0 ? `${syncState.lastUid + 1}:*` : '1:*';
			let maxUid = syncState.lastUid;
			let count = 0;

			for await (const msg of client.fetch(range, { uid: true, source: true })) {
				if (msg.uid <= syncState.lastUid) continue;

				try {
					const parsed = await simpleParser(msg.source);

					const fromAddr = (parsed.from?.value?.[0]?.address || '').toLowerCase();
					const fromName = parsed.from?.value?.[0]?.name || '';
					const subject = parsed.subject || '(no subject)';
					const messageId = parsed.messageId || null;
					const inReplyTo = parsed.inReplyTo || null;
					const references = parsed.references ? (Array.isArray(parsed.references) ? parsed.references : [parsed.references]) : [];
					const toAddrs = parsed.to?.value?.map((a: any) => a.address).filter(Boolean) || [];
					const ccAddrs = parsed.cc?.value?.map((a: any) => a.address).filter(Boolean) || [];

					// ── Reply loop detection ──
					const isAutoReply =
						fromAddr.endsWith('-agent@rspace.online') ||
						fromAddr.startsWith('noreply@') ||
						fromAddr.startsWith('no-reply@') ||
						fromAddr.startsWith('mailer-daemon@') ||
						fromAddr.startsWith('postmaster@') ||
						/^(auto[_-]?reply|out[_-]?of[_-]?office)/i.test(subject) ||
						parsed.headers?.get('auto-submitted')?.toString() !== undefined &&
						parsed.headers?.get('auto-submitted')?.toString() !== 'no' ||
						parsed.headers?.get('x-auto-response-suppress') !== undefined;

					if (isAutoReply) {
						// Store as skipped thread but don't trigger MI processing
						const skipThreadId = generateId();
						_syncServer!.changeDoc<MailboxDoc>(agentDoc.docId, `Agent IMAP (skipped auto-reply): ${subject}`, (d) => {
							d.threads[skipThreadId] = {
								id: skipThreadId,
								mailboxId: d.mailbox.id,
								messageId,
								subject,
								fromAddress: fromAddr,
								fromName,
								toAddresses: toAddrs,
								ccAddresses: ccAddrs,
								bodyText: parsed.text || '',
								bodyHtml: parsed.html || '',
								tags: ['agent-inbound-skipped'],
								status: 'closed',
								isRead: true,
								isStarred: false,
								assignedTo: null,
								hasAttachments: parsed.attachments?.length > 0 || false,
								receivedAt: parsed.date?.getTime() || Date.now(),
								createdAt: Date.now(),
								comments: [],
								inReplyTo: inReplyTo || null,
								references,
								direction: 'inbound',
								parentThreadId: null,
							};
						});
						console.log(`[Inbox] Agent ${creds.email} skipped auto-reply from ${fromAddr}: "${subject}"`);
						if (msg.uid > maxUid) maxUid = msg.uid;
						count++;
						continue;
					}

					const threadId = generateId();

					_syncServer!.changeDoc<MailboxDoc>(agentDoc.docId, `Agent IMAP: ${subject}`, (d) => {
						d.threads[threadId] = {
							id: threadId,
							mailboxId: d.mailbox.id,
							messageId,
							subject,
							fromAddress: fromAddr,
							fromName,
							toAddresses: toAddrs,
							ccAddresses: ccAddrs,
							bodyText: parsed.text || '',
							bodyHtml: parsed.html || '',
							tags: ['agent-inbound'],
							status: 'open',
							isRead: false,
							isStarred: false,
							assignedTo: null,
							hasAttachments: parsed.attachments?.length > 0 || false,
							receivedAt: parsed.date?.getTime() || Date.now(),
							createdAt: Date.now(),
							comments: [],
							inReplyTo: inReplyTo || null,
							references,
							direction: 'inbound',
							parentThreadId: null,
						};
					});

					count++;
					// Process via MI
					processAgentMI(agentDoc.docId, threadId, creds.spaceSlug);
				} catch (parseErr) {
					console.error(`[Inbox] Agent parse error UID ${msg.uid} (${creds.email}):`, parseErr);
				}

				if (msg.uid > maxUid) maxUid = msg.uid;
			}

			syncState.lastUid = maxUid;
			syncState.uidValidity = uidValidity || null;
			syncState.lastSyncAt = Date.now();
			syncState.error = null;

			if (count > 0) console.log(`[Inbox] Agent ${creds.email} synced ${count} messages`);
		} finally {
			lock.release();
		}

		await client.logout();
	} catch (e: any) {
		if (e.message?.includes('Nothing to fetch')) return;
		console.error(`[Inbox] Agent IMAP sync error (${creds.email}):`, e.message);
		syncState.error = e.message;
		syncState.lastSyncAt = Date.now();
	}
}

/** Sync all per-space agent mailboxes */
async function syncAllAgentMailboxes() {
	const creds = await getAgentCredentials();
	for (const c of creds) {
		await syncAgentMailbox(c);
	}
}

// ── Agent reply rate limiting ──
const _agentReplyTracker = new Map<string, { count: number; windowStart: number }>();
const AGENT_RATE_LIMIT = 3;        // max replies per sender per window
const AGENT_RATE_WINDOW = 3600000; // 1 hour

const _agentDailySends = new Map<string, { count: number; day: string }>();
const AGENT_DAILY_CAP = 50;        // max replies per agent mailbox per day

const AGENT_REPLY_MAX_LENGTH = 2000; // max characters in agent reply

/**
 * Process an inbound agent email through MI agentic loop.
 * Creates an auto-approved reply and sends it via SMTP.
 */
async function processAgentMI(docId: string, threadId: string, spaceSlug: string) {
	const doc = _syncServer!.getDoc<MailboxDoc>(docId);
	if (!doc) return;

	const thread = doc.threads[threadId];
	if (!thread || !thread.fromAddress) return;

	// Find the agent inbox for personality/config
	const agent = Object.values(doc.agentInboxes)[0];
	if (!agent?.autoReply) return;

	// ── Per-sender rate limit ──
	const senderKey = `${spaceSlug}:${thread.fromAddress!.toLowerCase()}`;
	const now = Date.now();
	const tracker = _agentReplyTracker.get(senderKey);
	if (tracker) {
		if (now - tracker.windowStart < AGENT_RATE_WINDOW) {
			if (tracker.count >= AGENT_RATE_LIMIT) {
				console.warn(`[Inbox] Rate limit: ${thread.fromAddress} exceeded ${AGENT_RATE_LIMIT} replies/hr for ${spaceSlug}`);
				return;
			}
			tracker.count++;
		} else {
			tracker.count = 1;
			tracker.windowStart = now;
		}
	} else {
		_agentReplyTracker.set(senderKey, { count: 1, windowStart: now });
	}

	// ── Daily send cap per agent mailbox ──
	const today = new Date().toISOString().slice(0, 10);
	const dailyKey = spaceSlug;
	const daily = _agentDailySends.get(dailyKey);
	if (daily) {
		if (daily.day === today) {
			if (daily.count >= AGENT_DAILY_CAP) {
				console.warn(`[Inbox] Daily cap: ${spaceSlug}-agent hit ${AGENT_DAILY_CAP} replies for ${today}`);
				return;
			}
			daily.count++;
		} else {
			daily.count = 1;
			daily.day = today;
		}
	} else {
		_agentDailySends.set(dailyKey, { count: 1, day: today });
	}

	try {
		const { miRegistry } = await import('../../server/mi-provider');
		const { runAgenticLoop } = await import('../../server/mi-agent');

		// Pick a provider (prefer gemini-flash for speed)
		const resolved = miRegistry.resolveModel('gemini-flash') || miRegistry.resolveModel(miRegistry.getDefaultModel());
		if (!resolved) {
			console.warn(`[Inbox] No MI provider available for agent reply`);
			return;
		}
		const { provider, providerModel } = resolved;

		const systemPrompt = `${agent.personality}\n\nYou received an email to ${spaceSlug}-agent@rspace.online.\nFrom: ${thread.fromName || thread.fromAddress}\nSubject: ${thread.subject}\n\nDraft a helpful, concise reply. Do NOT include any [MI_ACTION:...] markers. Just write the reply text.`;

		const messages = [
			{ role: 'system' as const, content: systemPrompt },
			{ role: 'user' as const, content: thread.bodyText || '(empty email body)' },
		];

		// Collect the full response from the agentic loop
		const stream = runAgenticLoop({
			messages,
			provider,
			providerModel,
			space: spaceSlug,
			maxTurns: 1,
		});

		const reader = stream.getReader();
		let replyText = '';

		while (true) {
			const { done, value } = await reader.read();
			if (done) break;
			const line = new TextDecoder().decode(value);
			try {
				const parsed = JSON.parse(line);
				if (parsed.message?.content) replyText += parsed.message.content;
			} catch { /* skip non-JSON lines */ }
		}

		if (!replyText.trim()) return;

		// ── Reply length cap ──
		if (replyText.length > AGENT_REPLY_MAX_LENGTH) {
			replyText = replyText.slice(0, AGENT_REPLY_MAX_LENGTH) + '...[truncated]';
		}

		// Create auto-approved approval and execute it
		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 MI auto-reply`, (d) => {
			d.approvals[approvalId] = {
				id: approvalId,
				mailboxId: d.mailbox.id,
				threadId,
				authorId: `agent:${agent.id}`,
				subject: replySubject,
				bodyText: replyText.trim(),
				bodyHtml: '',
				toAddresses: [thread.fromAddress!],
				ccAddresses: [],
				status: 'APPROVED',
				requiredSignatures: 0,
				safeTxHash: null,
				createdAt: Date.now(),
				resolvedAt: Date.now(),
				signatures: [],
				inReplyTo: thread.messageId || null,
				references,
				replyType: 'reply',
			};
		});

		// Execute the auto-approved reply (sends via SMTP)
		executeApproval(docId, approvalId).catch((e) =>
			console.error(`[Inbox] Agent MI reply send error:`, e.message)
		);

		console.log(`[Inbox] MI agent replied to "${thread.subject}" for space ${spaceSlug}`);
	} catch (e: any) {
		console.error(`[Inbox] processAgentMI error for ${spaceSlug}:`, e.message);
	}
}

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);
			}

			// Sync per-space agent mailboxes
			await syncAllAgentMailboxes();
		} 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="/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?v=3"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rinbox/inbox.css">`,
	}));
});

// ── Seed template data ──

/**
 * Initialize a clean team inbox for a space: {space}@rspace.online
 * No demo threads — just an empty mailbox ready for multi-sig approval.
 */
function initSpaceInbox(space: string, ownerDid: 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 doc = Automerge.change(Automerge.init<MailboxDoc>(), 'init space inbox', (d) => {
		d.meta = { module: 'inbox', collection: 'mailboxes', version: 2, spaceSlug: space, createdAt: now };
		d.mailbox = {
			id: mbId, workspaceId: null, slug: space, name: `${space.charAt(0).toUpperCase() + space.slice(1)} Inbox`,
			email: `${space}@rspace.online`,
			description: `Team inbox for the ${space} space. Multi-sig approval can be configured via Gnosis Safe.`,
			visibility: 'members', ownerDid,
			safeAddress: null, safeChainId: null, approvalThreshold: 1, createdAt: now,
		};
		d.members = [];
		d.threads = {};
		d.approvals = {};
		d.personalInboxes = {};
		d.agentInboxes = {};
	});

	_syncServer.setDoc(docId, doc);
	console.log(`[Inbox] Team inbox provisioned for "${space}": ${space}@rspace.online`);
}

/**
 * Initialize an agent mailbox for a space: {space}-agent@rspace.online
 * Used by MI to receive + auto-reply to inbound emails.
 */
function initAgentMailbox(space: string, ownerDid: string) {
	if (!_syncServer) return;

	// Check if agent mailbox already exists for this space
	const prefix = `${space}:inbox:mailboxes:`;
	const existing = _syncServer.listDocs().filter((id) => id.startsWith(prefix));
	for (const id of existing) {
		const d = _syncServer.getDoc<MailboxDoc>(id);
		if (d && d.mailbox.slug === `${space}-agent`) return;
	}

	const mbId = crypto.randomUUID();
	const docId = mailboxDocId(space, mbId);
	const agentId = crypto.randomUUID();
	const now = Date.now();

	const doc = Automerge.change(Automerge.init<MailboxDoc>(), 'init agent mailbox', (d) => {
		d.meta = { module: 'inbox', collection: 'mailboxes', version: 2, spaceSlug: space, createdAt: now };
		d.mailbox = {
			id: mbId, workspaceId: null, slug: `${space}-agent`,
			name: `MI Agent`,
			email: `${space}-agent@rspace.online`,
			description: `Agent mailbox for the ${space} space — inbound emails are processed by MI.`,
			visibility: 'members', ownerDid,
			safeAddress: null, safeChainId: null, approvalThreshold: 0, createdAt: now,
		};
		d.members = [];
		d.threads = {};
		d.approvals = {};
		d.personalInboxes = {};
		d.agentInboxes = {
			[agentId]: {
				id: agentId,
				spaceSlug: space,
				name: 'MI Agent',
				email: `${space}-agent@rspace.online`,
				personality: `You are the MI (Mycelial Intelligence) agent for the "${space}" space. You help members by answering questions about the space, its activities, and connecting people. Be helpful, concise, and friendly.`,
				autoReply: true,
				autoClassify: false,
				rules: [{ match: { field: 'subject', pattern: '.*' }, action: 'reply' }],
			},
		};
	});

	_syncServer.setDoc(docId, doc);
	console.log(`[Inbox] Agent mailbox provisioned for "${space}": ${space}-agent@rspace.online`);
}

/**
 * Seed demo mailbox with sample threads (for demo/template spaces only).
 */
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`);
}

/**
 * Bootstrap mailboxes for spaces created before rinbox existed.
 * Iterates all space docs and ensures each has team inbox + agent mailbox.
 */
async function bootstrapExistingSpaces() {
	if (!_syncServer) return;

	try {
		// Collect all known space slugs from existing inbox docs
		const existingSlugs = new Set<string>();
		const existingAgentSlugs = new Set<string>();
		for (const id of _syncServer.listDocs()) {
			const parts = id.split(':');
			if (parts.length >= 4 && parts[1] === 'inbox' && parts[2] === 'mailboxes') {
				const doc = _syncServer.getDoc<MailboxDoc>(id);
				if (doc) {
					if (doc.mailbox.slug.endsWith('-agent')) {
						existingAgentSlugs.add(parts[0]);
					} else {
						existingSlugs.add(parts[0]);
					}
				}
			}
		}

		// Find all space slugs from ANY doc on the SyncServer (spaces module stores space docs)
		const allSpaceSlugs = new Set<string>();
		for (const id of _syncServer.listDocs()) {
			const parts = id.split(':');
			// Space docs follow pattern: {space}:spaces:... or just use the first segment
			if (parts.length >= 2 && parts[0] !== 'global') {
				allSpaceSlugs.add(parts[0]);
			}
		}

		let bootstrapped = 0;
		for (const slug of allSpaceSlugs) {
			if (slug === 'demo' || slug === 'template') continue; // skip demo/template

			if (!existingSlugs.has(slug)) {
				initSpaceInbox(slug, 'did:bootstrap');
				// Provision Mailcow alias
				fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${slug}/alias`, { method: 'POST' }).catch(() => {});
				bootstrapped++;
			}
			if (!existingAgentSlugs.has(slug)) {
				initAgentMailbox(slug, 'did:bootstrap');
				// Provision Mailcow agent mailbox
				fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${slug}/agent-mailbox`, { method: 'POST' }).catch(() => {});
				bootstrapped++;
			}
		}

		if (bootstrapped > 0) {
			console.log(`[Inbox] Bootstrap: provisioned ${bootstrapped} missing mailboxes across ${allSpaceSlugs.size} spaces`);
		} else {
			console.log(`[Inbox] Bootstrap: all ${allSpaceSlugs.size} spaces already have mailboxes`);
		}
	} catch (e: any) {
		console.error(`[Inbox] Bootstrap error:`, e.message);
	}
}

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(() => {});
		// Bootstrap mailboxes for spaces created before rinbox existed
		setTimeout(() => bootstrapExistingSpaces(), 10000);
	},
	async onSpaceCreate(ctx) {
		initSpaceInbox(ctx.spaceSlug, ctx.ownerDID || 'did:unknown');
		initAgentMailbox(ctx.spaceSlug, ctx.ownerDID || 'did:unknown');
		// Provision Mailcow forwarding alias for {space}@rspace.online
		fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/alias`, { method: 'POST' })
			.catch((e) => console.error(`[Inbox] Failed to provision space alias for ${ctx.spaceSlug}:`, e));
		// Provision agent mailbox {space}-agent@rspace.online
		fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/agent-mailbox`, { method: 'POST' })
			.catch((e) => console.error(`[Inbox] Failed to provision agent mailbox for ${ctx.spaceSlug}:`, e));
	},
	async onSpaceDelete(ctx) {
		// Deprovision Mailcow forwarding alias
		fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/alias`, { method: 'DELETE' })
			.catch((e) => console.error(`[Inbox] Failed to deprovision space alias for ${ctx.spaceSlug}:`, e));
		// Deprovision agent mailbox
		fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/agent-mailbox`, { method: 'DELETE' })
			.catch((e) => console.error(`[Inbox] Failed to deprovision agent mailbox for ${ctx.spaceSlug}:`, e));
	},
	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"],
	outputPaths: [
		{ path: "mailboxes", name: "Mailboxes", icon: "📮", description: "Email mailboxes and inboxes" },
	],
	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." },
			],
		},
	],
	onboardingActions: [
		{ label: "Connect your Email", icon: "📧", description: "Link an email account to receive messages", type: 'link', href: '/{space}/rinbox?action=connect-email' },
		{ label: "Create a Mailbox", icon: "📬", description: "Set up a shared team mailbox", type: 'create', href: '/{space}/rinbox' },
	],
};

// ── MI Data Export ──

const INBOX_PREFIX = ":inbox:mailboxes:";

export interface MIThreadItem {
	subject: string;
	fromAddress: string | null;
	status: string;
	isRead: boolean;
	receivedAt: number;
}

export function getRecentThreadsForMI(space: string, limit = 5): MIThreadItem[] {
	if (!_syncServer) return [];
	const prefix = `${space}${INBOX_PREFIX}`;
	const all: MIThreadItem[] = [];

	for (const docId of _syncServer.getDocIds()) {
		if (!docId.startsWith(prefix)) continue;
		const doc = _syncServer.getDoc<MailboxDoc>(docId);
		if (!doc?.threads) continue;
		for (const t of Object.values(doc.threads)) {
			all.push({
				subject: t.subject,
				fromAddress: t.fromAddress,
				status: t.status,
				isRead: t.isRead,
				receivedAt: t.createdAt,
			});
		}
	}

	return all
		.sort((a, b) => b.receivedAt - a.receivedAt)
		.slice(0, limit);
}
