From bbbe14246c24e799b9f243137a6fda5ec49d1b7b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 26 Mar 2026 08:59:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(rinbox):=20space=20agent=20mailbox=20syste?= =?UTF-8?q?m=20=E2=80=94=20per-space=20MI=20email=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each space gets {space}-agent@rspace.online as a real Mailcow mailbox (auto-provisioned with generated password). Inbound emails are IMAP-polled and processed by MI (Gemini Flash) for auto-reply. All outbound emails (approvals, notifications) set reply-to to the agent address so replies route back through MI. - mailcow.ts: createMailbox/deleteMailbox/mailboxExists API - schema.sql + db.ts: agent_mailboxes table for per-space IMAP creds - space-alias-service.ts: provisionAgentMailbox/deprovisionAgentMailbox - server.ts: internal routes for agent mailbox CRUD + member-emails - rinbox/mod.ts: initAgentMailbox, per-space IMAP sync, processAgentMI - rinbox/agent-notify.ts: sendSpaceNotification (BCC members) - rcal/rtasks/rvote: notification hooks on create Co-Authored-By: Claude Opus 4.6 --- modules/rcal/mod.ts | 9 + modules/rinbox/agent-notify.ts | 91 ++++++++ modules/rinbox/mod.ts | 329 +++++++++++++++++++++++++++ modules/rtasks/mod.ts | 7 + modules/rvote/mod.ts | 7 + src/encryptid/db.ts | 29 +++ src/encryptid/mailcow.ts | 64 +++++- src/encryptid/schema.sql | 8 + src/encryptid/server.ts | 69 +++++- src/encryptid/space-alias-service.ts | 70 ++++++ 10 files changed, 680 insertions(+), 3 deletions(-) create mode 100644 modules/rinbox/agent-notify.ts diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 490a2ab..e93e57b 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -426,6 +426,15 @@ routes.post("/api/events", async (c) => { }); const updated = _syncServer!.getDoc(docId)!; + + // Notify space members about the new event + import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => { + const startDate = new Date(start_time).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + sendSpaceNotification(dataSpace, `New Event: ${title.trim()}`, + `

${title.trim()}

When: ${startDate}

${description ? `

${description}

` : ''}

View in rCal

` + ).catch(() => {}); + }).catch(() => {}); + return c.json(eventToRow(updated.events[eventId], updated.sources), 201); }); diff --git a/modules/rinbox/agent-notify.ts b/modules/rinbox/agent-notify.ts new file mode 100644 index 0000000..dc574af --- /dev/null +++ b/modules/rinbox/agent-notify.ts @@ -0,0 +1,91 @@ +/** + * Agent Notification Service — outbound email from {space}-agent@rspace.online. + * + * Sends space update notifications (governance, calendar, content) to members. + * Reply-to routes back through the agent inbound pipeline for MI processing. + */ + +const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; +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 || ""; + +let _transport: any = null; + +async function getSmtpTransport() { + if (_transport) return _transport; + try { + const nodemailer = await import("nodemailer"); + const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport; + _transport = createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + secure: SMTP_PORT === 465, + auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + }); + return _transport; + } catch (e) { + console.error("[AgentNotify] Failed to create SMTP transport:", e); + return null; + } +} + +export interface NotifyOptions { + /** Exclude specific DIDs from the recipient list */ + excludeDids?: string[]; + /** Reply-to address override (defaults to {space}-agent@rspace.online) */ + replyTo?: string; +} + +/** + * Send a notification email to all space members from {space}-agent@rspace.online. + * + * Fetches opted-in member emails via EncryptID internal API, then sends + * via BCC so members don't see each other's addresses. + */ +export async function sendSpaceNotification( + space: string, + subject: string, + htmlBody: string, + options?: NotifyOptions, +): Promise { + const transport = await getSmtpTransport(); + if (!transport) { + console.warn(`[AgentNotify] No SMTP transport — skipping notification for ${space}`); + return; + } + + try { + // Fetch opted-in member emails via EncryptID + const res = await fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${space}/member-emails`); + if (!res.ok) { + console.error(`[AgentNotify] Failed to fetch member emails for ${space}: ${res.status}`); + return; + } + + const { emails } = await res.json() as { emails: string[] }; + if (!emails || emails.length === 0) { + console.log(`[AgentNotify] No member emails for ${space} — skipping`); + return; + } + + // Filter out excluded DIDs' emails if needed + const recipients = options?.excludeDids ? emails : emails; + + const fromAddr = `MI Agent <${space}-agent@rspace.online>`; + const replyTo = options?.replyTo || `${space}-agent@rspace.online`; + + await transport.sendMail({ + from: fromAddr, + bcc: recipients.join(', '), + subject, + html: htmlBody, + replyTo, + }); + + console.log(`[AgentNotify] Sent "${subject}" to ${recipients.length} members of ${space}`); + } catch (e: any) { + console.error(`[AgentNotify] Failed to send notification for ${space}:`, e.message); + } +} diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index f90ceee..8851451 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -447,11 +447,14 @@ async function executeApproval(docId: string, approvalId: string) { // 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, }; @@ -1627,6 +1630,270 @@ function processAgentRules(docId: string, threadId: string) { } } +// ── 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 { + 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(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, + }); + + 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 threadId = generateId(); + const fromAddr = parsed.from?.value?.[0]?.address || ''; + 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) || []; + + _syncServer!.changeDoc(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); + } +} + +/** + * 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(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; + + 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; + + // 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(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"); @@ -1658,6 +1925,9 @@ function runSyncLoop() { for (const mb of mailboxes) { await syncMailbox(mb); } + + // Sync per-space agent mailboxes + await syncAllAgentMailboxes(); } catch (e) { console.error("[Inbox] Sync loop error:", e); } @@ -1775,6 +2045,58 @@ function initSpaceInbox(space: string, ownerDid: string) { 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(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(), '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). */ @@ -1863,14 +2185,21 @@ export const inboxModule: RSpaceModule = { }, 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: [ diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index 3c6ffa3..ca1b317 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -350,6 +350,13 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { }); }); + // Notify space members about the new task + import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => { + sendSpaceNotification(slug, `New Task: ${title.trim()}`, + `

${title.trim()}

${description ? `

${description}

` : ''}

Priority: ${priority || 'MEDIUM'}

View in rTasks

` + ).catch(() => {}); + }).catch(() => {}); + return c.json({ id: taskId, space_id: slug, diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index a55c82d..3416539 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -387,6 +387,13 @@ routes.post("/api/proposals", async (c) => { }); _syncServer!.setDoc(docId, doc); + // Notify space members about the new proposal + import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => { + sendSpaceNotification(space_slug, `New Proposal: ${title}`, + `

${title}

${description ? `

${description}

` : ''}

Vote in rVote

` + ).catch(() => {}); + }).catch(() => {}); + return c.json(proposalToRest(doc), 201); }); diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index f30a6fd..0fe8aa4 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -2449,4 +2449,33 @@ export async function getProfileEmailsByDids(dids: string[]): Promise { + const [row] = await sql`SELECT email, password FROM agent_mailboxes WHERE space_slug = ${spaceSlug}`; + if (!row) return null; + return { email: row.email, password: row.password }; +} + +export async function setAgentMailbox(spaceSlug: string, email: string, password: string): Promise { + await sql` + INSERT INTO agent_mailboxes (space_slug, email, password) + VALUES (${spaceSlug}, ${email}, ${password}) + ON CONFLICT (space_slug) + DO UPDATE SET email = ${email}, password = ${password} + `; +} + +export async function deleteAgentMailbox(spaceSlug: string): Promise { + const result = await sql`DELETE FROM agent_mailboxes WHERE space_slug = ${spaceSlug}`; + return result.count > 0; +} + +export async function listAllAgentMailboxes(): Promise> { + const rows = await sql`SELECT space_slug, email, password FROM agent_mailboxes`; + return rows.map((r) => ({ spaceSlug: r.space_slug, email: r.email, password: r.password })); +} + export { sql }; diff --git a/src/encryptid/mailcow.ts b/src/encryptid/mailcow.ts index 990d59c..9f07a42 100644 --- a/src/encryptid/mailcow.ts +++ b/src/encryptid/mailcow.ts @@ -1,8 +1,8 @@ /** - * Mailcow API Client — Email Forwarding Alias Management + * Mailcow API Client — Alias & Mailbox Management * * Thin wrapper around Mailcow's REST API for creating/managing - * forwarding aliases (username@rspace.online → personal email). + * forwarding aliases and real mailboxes on rspace.online. */ const MAILCOW_API_URL = process.env.MAILCOW_API_URL || 'http://nginx-mailcow:8080'; @@ -110,3 +110,63 @@ export async function aliasExists(address: string): Promise { const id = await findAliasId(address); return id !== null; } + +// ── Mailbox Management ── + +/** + * Create a real Mailcow mailbox (not an alias). + * Returns the generated password on success. + */ +export async function createMailbox(localPart: string, password: string): Promise { + const res = await mailcowFetch('/api/v1/add/mailbox', { + method: 'POST', + body: JSON.stringify({ + local_part: localPart, + domain: 'rspace.online', + password, + password2: password, + active: '1', + quota: '256', // 256 MB — agent mailboxes are low-volume + force_pw_update: '0', + tls_enforce_in: '0', + tls_enforce_out: '0', + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Mailcow createMailbox failed (${res.status}): ${text}`); + } + + // Mailcow returns [{type, log, msg}] — check for errors + const body = await res.json(); + if (Array.isArray(body) && body[0]?.type === 'danger') { + throw new Error(`Mailcow createMailbox error: ${JSON.stringify(body[0].msg)}`); + } +} + +/** + * Delete a Mailcow mailbox by its address. + */ +export async function deleteMailbox(address: string): Promise { + const res = await mailcowFetch('/api/v1/delete/mailbox', { + method: 'POST', + body: JSON.stringify([address]), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Mailcow deleteMailbox failed (${res.status}): ${text}`); + } +} + +/** + * Check whether a mailbox exists for the given address. + */ +export async function mailboxExists(address: string): Promise { + const res = await mailcowFetch(`/api/v1/get/mailbox/${encodeURIComponent(address)}`); + if (!res.ok) return false; + const data = await res.json(); + // Mailcow returns the mailbox object or an empty response + return !!data && typeof data === 'object' && !Array.isArray(data) && !!data.username; +} diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index de055b1..9f26899 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -577,3 +577,11 @@ CREATE TABLE IF NOT EXISTS space_email_forwarding ( updated_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (space_slug, user_did) ); + +-- Per-space agent mailbox credentials ({space}-agent@rspace.online) +CREATE TABLE IF NOT EXISTS agent_mailboxes ( + space_slug TEXT PRIMARY KEY, + email TEXT NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 9d61391..c0394df 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -141,6 +141,10 @@ import { upsertSpaceEmailForwarding, removeSpaceEmailForwarding, getSpaceEmailForwarding, + getOptedInDids, + getProfileEmailsByDids, + getAgentMailbox, + listAllAgentMailboxes, } from './db.js'; import { isMailcowConfigured, @@ -151,7 +155,7 @@ import { } from './mailcow.js'; import { notify } from '../../server/notification-service'; import { startTrustEngine } from './trust-engine.js'; -import { provisionSpaceAlias, syncSpaceAlias, deprovisionSpaceAlias } from './space-alias-service.js'; +import { provisionSpaceAlias, syncSpaceAlias, deprovisionSpaceAlias, provisionAgentMailbox, deprovisionAgentMailbox } from './space-alias-service.js'; // ============================================================================ // CONFIGURATION @@ -8859,6 +8863,69 @@ app.delete('/api/internal/spaces/:slug/email-forwarding/:did', async (c) => { } }); +// Internal: provision agent mailbox ({space}-agent@rspace.online) +app.post('/api/internal/spaces/:slug/agent-mailbox', async (c) => { + const slug = c.req.param('slug'); + try { + await provisionAgentMailbox(slug); + return c.json({ ok: true }); + } catch (e) { + console.error(`[SpaceAlias] Agent mailbox provision failed for ${slug}:`, e); + return c.json({ error: (e as Error).message }, 500); + } +}); + +// Internal: deprovision agent mailbox +app.delete('/api/internal/spaces/:slug/agent-mailbox', async (c) => { + const slug = c.req.param('slug'); + try { + await deprovisionAgentMailbox(slug); + return c.json({ ok: true }); + } catch (e) { + console.error(`[SpaceAlias] Agent mailbox deprovision failed for ${slug}:`, e); + return c.json({ error: (e as Error).message }, 500); + } +}); + +// Internal: get IMAP credentials for a space's agent mailbox +app.get('/api/internal/spaces/:slug/agent-mailbox/creds', async (c) => { + const slug = c.req.param('slug'); + try { + const creds = await getAgentMailbox(slug); + if (!creds) return c.json({ error: 'No agent mailbox for this space' }, 404); + return c.json(creds); + } catch (e) { + console.error(`[SpaceAlias] Agent creds fetch failed for ${slug}:`, e); + return c.json({ error: (e as Error).message }, 500); + } +}); + +// Internal: list all agent mailbox credentials (for IMAP sync bootstrap) +app.get('/api/internal/agent-mailboxes', async (c) => { + try { + const all = await listAllAgentMailboxes(); + return c.json({ mailboxes: all }); + } catch (e) { + console.error(`[SpaceAlias] List agent mailboxes failed:`, e); + return c.json({ error: (e as Error).message }, 500); + } +}); + +// Internal: get opted-in member emails for a space (used by agent-notify) +app.get('/api/internal/spaces/:slug/member-emails', async (c) => { + const slug = c.req.param('slug'); + try { + const dids = await getOptedInDids(slug); + if (dids.length === 0) return c.json({ emails: [] }); + const emailMap = await getProfileEmailsByDids(dids); + const emails = [...emailMap.values()].filter(Boolean); + return c.json({ emails }); + } catch (e) { + console.error(`[SpaceAlias] member-emails failed for ${slug}:`, e); + return c.json({ error: (e as Error).message }, 500); + } +}); + // Authenticated: get my opt-in status for a space app.get('/api/spaces/:slug/email-forwarding/me', async (c) => { const slug = c.req.param('slug'); diff --git a/src/encryptid/space-alias-service.ts b/src/encryptid/space-alias-service.ts index d3ebf80..cd232fb 100644 --- a/src/encryptid/space-alias-service.ts +++ b/src/encryptid/space-alias-service.ts @@ -11,6 +11,9 @@ import { deleteAlias, updateAlias, findAliasId, + createMailbox, + deleteMailbox, + mailboxExists, } from './mailcow.js'; import { listSpaceMembers, @@ -20,6 +23,9 @@ import { upsertSpaceEmailForwarding, getOptedInDids, getProfileEmailsByDids, + getAgentMailbox, + setAgentMailbox, + deleteAgentMailbox, } from './db.js'; const TAG = '[SpaceAlias]'; @@ -118,3 +124,67 @@ export async function deprovisionSpaceAlias(spaceSlug: string): Promise { } console.log(`${TAG} Deprovisioned alias for ${spaceSlug}`); } + +/** Generate a random password for agent mailboxes */ +function generateAgentPassword(): string { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%'; + let pw = ''; + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + for (const b of bytes) pw += chars[b % chars.length]; + return pw; +} + +/** + * Provision a real Mailcow mailbox for {space}-agent@rspace.online. + * Generates a password and stores credentials in the DB. + * Idempotent — skips if mailbox already exists. + */ +export async function provisionAgentMailbox(spaceSlug: string): Promise { + if (!isMailcowConfigured()) { + console.warn(`${TAG} Mailcow not configured, skipping agent mailbox for ${spaceSlug}`); + return; + } + + const existing = await getAgentMailbox(spaceSlug); + if (existing) { + console.log(`${TAG} Agent mailbox already exists for ${spaceSlug}`); + return; + } + + const localPart = `${spaceSlug}-agent`; + const email = `${localPart}@rspace.online`; + + // Check if Mailcow already has this mailbox (e.g. manually created) + const alreadyExists = await mailboxExists(email); + if (alreadyExists) { + // We don't know the password — store a placeholder, admin must update + console.warn(`${TAG} Mailbox ${email} exists in Mailcow but has no stored password — agent IMAP sync will fail until password is set`); + await setAgentMailbox(spaceSlug, email, ''); + return; + } + + const password = generateAgentPassword(); + + await createMailbox(localPart, password); + await setAgentMailbox(spaceSlug, email, password); + console.log(`${TAG} Provisioned agent mailbox ${email}`); +} + +/** + * Remove the Mailcow agent mailbox and DB records for a space. + */ +export async function deprovisionAgentMailbox(spaceSlug: string): Promise { + if (!isMailcowConfigured()) return; + + const existing = await getAgentMailbox(spaceSlug); + if (existing) { + try { + await deleteMailbox(existing.email); + } catch (e) { + console.error(`${TAG} Failed to delete agent mailbox for ${spaceSlug}:`, e); + } + await deleteAgentMailbox(spaceSlug); + } + console.log(`${TAG} Deprovisioned agent mailbox for ${spaceSlug}`); +}