Merge branch 'dev'
This commit is contained in:
commit
0e52a14c37
|
|
@ -426,6 +426,15 @@ routes.post("/api/events", async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
|
const updated = _syncServer!.getDoc<CalendarDoc>(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()}`,
|
||||||
|
`<h3>${title.trim()}</h3><p><strong>When:</strong> ${startDate}</p>${description ? `<p>${description}</p>` : ''}<p><a href="https://rspace.online/${dataSpace}/rcal">View in rCal</a></p>`
|
||||||
|
).catch(() => {});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return c.json(eventToRow(updated.events[eventId], updated.sources), 201);
|
return c.json(eventToRow(updated.events[eventId], updated.sources), 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -447,11 +447,14 @@ async function executeApproval(docId: string, approvalId: string) {
|
||||||
|
|
||||||
// Find the mailbox to get the from address
|
// Find the mailbox to get the from address
|
||||||
const mailboxEmail = doc.mailbox.email;
|
const mailboxEmail = doc.mailbox.email;
|
||||||
|
const spaceSlug = docId.split(':')[0];
|
||||||
|
const agentReplyTo = `${spaceSlug}-agent@rspace.online`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mailOptions: any = {
|
const mailOptions: any = {
|
||||||
from: mailboxEmail,
|
from: mailboxEmail,
|
||||||
to: approval.toAddresses.join(', '),
|
to: approval.toAddresses.join(', '),
|
||||||
|
replyTo: agentReplyTo,
|
||||||
subject: approval.subject,
|
subject: approval.subject,
|
||||||
text: approval.bodyText,
|
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<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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
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<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() {
|
function runSyncLoop() {
|
||||||
if (!IMAP_HOST) {
|
if (!IMAP_HOST) {
|
||||||
console.log("[Inbox] IMAP_HOST not set — IMAP sync disabled");
|
console.log("[Inbox] IMAP_HOST not set — IMAP sync disabled");
|
||||||
|
|
@ -1658,6 +1925,9 @@ function runSyncLoop() {
|
||||||
for (const mb of mailboxes) {
|
for (const mb of mailboxes) {
|
||||||
await syncMailbox(mb);
|
await syncMailbox(mb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync per-space agent mailboxes
|
||||||
|
await syncAllAgentMailboxes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Inbox] Sync loop error:", 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`);
|
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).
|
* Seed demo mailbox with sample threads (for demo/template spaces only).
|
||||||
*/
|
*/
|
||||||
|
|
@ -1863,14 +2185,21 @@ export const inboxModule: RSpaceModule = {
|
||||||
},
|
},
|
||||||
async onSpaceCreate(ctx) {
|
async onSpaceCreate(ctx) {
|
||||||
initSpaceInbox(ctx.spaceSlug, ctx.ownerDID || 'did:unknown');
|
initSpaceInbox(ctx.spaceSlug, ctx.ownerDID || 'did:unknown');
|
||||||
|
initAgentMailbox(ctx.spaceSlug, ctx.ownerDID || 'did:unknown');
|
||||||
// Provision Mailcow forwarding alias for {space}@rspace.online
|
// Provision Mailcow forwarding alias for {space}@rspace.online
|
||||||
fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/alias`, { method: 'POST' })
|
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));
|
.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) {
|
async onSpaceDelete(ctx) {
|
||||||
// Deprovision Mailcow forwarding alias
|
// Deprovision Mailcow forwarding alias
|
||||||
fetch(`${ENCRYPTID_INTERNAL}/api/internal/spaces/${ctx.spaceSlug}/alias`, { method: 'DELETE' })
|
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));
|
.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",
|
standaloneDomain: "rinbox.online",
|
||||||
feeds: [
|
feeds: [
|
||||||
|
|
|
||||||
|
|
@ -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()}`,
|
||||||
|
`<h3>${title.trim()}</h3>${description ? `<p>${description}</p>` : ''}<p><strong>Priority:</strong> ${priority || 'MEDIUM'}</p><p><a href="https://rspace.online/${slug}/rtasks">View in rTasks</a></p>`
|
||||||
|
).catch(() => {});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
space_id: slug,
|
space_id: slug,
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,13 @@ routes.post("/api/proposals", async (c) => {
|
||||||
});
|
});
|
||||||
_syncServer!.setDoc(docId, doc);
|
_syncServer!.setDoc(docId, doc);
|
||||||
|
|
||||||
|
// Notify space members about the new proposal
|
||||||
|
import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => {
|
||||||
|
sendSpaceNotification(space_slug, `New Proposal: ${title}`,
|
||||||
|
`<h3>${title}</h3>${description ? `<p>${description}</p>` : ''}<p><a href="https://rspace.online/${space_slug}/rvote">Vote in rVote</a></p>`
|
||||||
|
).catch(() => {});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return c.json(proposalToRest(doc), 201);
|
return c.json(proposalToRest(doc), 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2449,4 +2449,33 @@ export async function getProfileEmailsByDids(dids: string[]): Promise<Map<string
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AGENT MAILBOX OPERATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getAgentMailbox(spaceSlug: string): Promise<{ email: string; password: string } | null> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
const result = await sql`DELETE FROM agent_mailboxes WHERE space_slug = ${spaceSlug}`;
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAllAgentMailboxes(): Promise<Array<{ spaceSlug: string; email: string; password: string }>> {
|
||||||
|
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 };
|
export { sql };
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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';
|
const MAILCOW_API_URL = process.env.MAILCOW_API_URL || 'http://nginx-mailcow:8080';
|
||||||
|
|
@ -110,3 +110,63 @@ export async function aliasExists(address: string): Promise<boolean> {
|
||||||
const id = await findAliasId(address);
|
const id = await findAliasId(address);
|
||||||
return id !== null;
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -577,3 +577,11 @@ CREATE TABLE IF NOT EXISTS space_email_forwarding (
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
PRIMARY KEY (space_slug, user_did)
|
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()
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,10 @@ import {
|
||||||
upsertSpaceEmailForwarding,
|
upsertSpaceEmailForwarding,
|
||||||
removeSpaceEmailForwarding,
|
removeSpaceEmailForwarding,
|
||||||
getSpaceEmailForwarding,
|
getSpaceEmailForwarding,
|
||||||
|
getOptedInDids,
|
||||||
|
getProfileEmailsByDids,
|
||||||
|
getAgentMailbox,
|
||||||
|
listAllAgentMailboxes,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
isMailcowConfigured,
|
isMailcowConfigured,
|
||||||
|
|
@ -151,7 +155,7 @@ import {
|
||||||
} from './mailcow.js';
|
} from './mailcow.js';
|
||||||
import { notify } from '../../server/notification-service';
|
import { notify } from '../../server/notification-service';
|
||||||
import { startTrustEngine } from './trust-engine.js';
|
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
|
// 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
|
// Authenticated: get my opt-in status for a space
|
||||||
app.get('/api/spaces/:slug/email-forwarding/me', async (c) => {
|
app.get('/api/spaces/:slug/email-forwarding/me', async (c) => {
|
||||||
const slug = c.req.param('slug');
|
const slug = c.req.param('slug');
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import {
|
||||||
deleteAlias,
|
deleteAlias,
|
||||||
updateAlias,
|
updateAlias,
|
||||||
findAliasId,
|
findAliasId,
|
||||||
|
createMailbox,
|
||||||
|
deleteMailbox,
|
||||||
|
mailboxExists,
|
||||||
} from './mailcow.js';
|
} from './mailcow.js';
|
||||||
import {
|
import {
|
||||||
listSpaceMembers,
|
listSpaceMembers,
|
||||||
|
|
@ -20,6 +23,9 @@ import {
|
||||||
upsertSpaceEmailForwarding,
|
upsertSpaceEmailForwarding,
|
||||||
getOptedInDids,
|
getOptedInDids,
|
||||||
getProfileEmailsByDids,
|
getProfileEmailsByDids,
|
||||||
|
getAgentMailbox,
|
||||||
|
setAgentMailbox,
|
||||||
|
deleteAgentMailbox,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
|
|
||||||
const TAG = '[SpaceAlias]';
|
const TAG = '[SpaceAlias]';
|
||||||
|
|
@ -118,3 +124,67 @@ export async function deprovisionSpaceAlias(spaceSlug: string): Promise<void> {
|
||||||
}
|
}
|
||||||
console.log(`${TAG} Deprovisioned alias for ${spaceSlug}`);
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue