feat(rinbox): space agent mailbox system — per-space MI email identity
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 <noreply@anthropic.com>
This commit is contained in:
parent
e31c905d7c
commit
bbbe14246c
|
|
@ -426,6 +426,15 @@ routes.post("/api/events", async (c) => {
|
|||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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<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() {
|
||||
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<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).
|
||||
*/
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
id: taskId,
|
||||
space_id: slug,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
`<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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2449,4 +2449,33 @@ export async function getProfileEmailsByDids(dids: string[]): Promise<Map<string
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
|||
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<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(),
|
||||
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,
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
}
|
||||
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