diff --git a/apps/commandboard-api/package.json b/apps/commandboard-api/package.json index ef70c40..0f31698 100644 --- a/apps/commandboard-api/package.json +++ b/apps/commandboard-api/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@logicsrc/plugin-core": "file:../../packages/plugin-core", + "@logicsrc/plugin-agentmail": "file:../../plugins/agentmail", "@logicsrc/plugin-c0mpute": "file:../../plugins/c0mpute", "@logicsrc/plugin-coinpay": "file:../../plugins/coinpay", "@logicsrc/plugin-email-accounts": "file:../../plugins/email-accounts", @@ -20,9 +21,14 @@ "@logicsrc/plugin-sh1pt": "file:../../plugins/sh1pt", "@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts", "@logicsrc/plugin-ugig": "file:../../plugins/ugig", - "@logicsrc/validators": "file:../../packages/validators" + "@logicsrc/validators": "file:../../packages/validators", + "imapflow": "^1.4.3", + "mailparser": "^3.9.12", + "nodemailer": "^9.0.1" }, "devDependencies": { + "@types/mailparser": "^3.4.6", + "@types/nodemailer": "^8.0.1", "tsx": "^4.21.0", "vitest": "^4.0.8" } diff --git a/apps/commandboard-api/src/agentmail.test.ts b/apps/commandboard-api/src/agentmail.test.ts new file mode 100644 index 0000000..48058d2 --- /dev/null +++ b/apps/commandboard-api/src/agentmail.test.ts @@ -0,0 +1,79 @@ +import type { Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createCommandBoardServer } from "./index.js"; + +// These exercise the AgentMail routes against the default in-memory transport +// (no AGENTMAIL_BACKEND=mailu), so no live mail server is required. +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + server = createCommandBoardServer(); + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected API server to bind to a local port"); + } + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}); + +describe("AgentMail API routes", () => { + it("lists mailboxes and exposes the member address", async () => { + const res = await fetch(`${baseUrl}/api/plugins/agentmail/mailboxes`); + expect(res.status).toBe(200); + const body = (await res.json()) as { address: string; mailboxes: unknown[] }; + expect(body.address).toBe("chovy@bbs.profullstack.com"); + expect(Array.isArray(body.mailboxes)).toBe(true); + }); + + it("registers agentmail in the plugin snapshot", async () => { + const res = await fetch(`${baseUrl}/api/plugins`); + const body = (await res.json()) as { plugins: { id: string }[] }; + expect(body.plugins.some((p) => p.id === "agentmail")).toBe(true); + }); + + it("sends a draft and stores it in Sent", async () => { + const send = await fetch(`${baseUrl}/api/plugins/agentmail/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: "qa@example.com", subject: "hi", text: "from a QA run" }) + }); + expect(send.status).toBe(201); + const result = (await send.json()) as { messageId: string }; + expect(result.messageId).toMatch(/@/); + + const sent = await fetch(`${baseUrl}/api/plugins/agentmail/mailboxes/Sent/messages`); + expect(sent.status).toBe(200); + const body = (await sent.json()) as { messages: { subject: string }[] }; + expect(body.messages.some((m) => m.subject === "hi")).toBe(true); + }); + + it("accepts a 'Name ' recipient string", async () => { + const send = await fetch(`${baseUrl}/api/plugins/agentmail/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: "QA Bot ", subject: "named", text: "x" }) + }); + expect(send.status).toBe(201); + }); + + it("rejects a search without a query", async () => { + const res = await fetch(`${baseUrl}/api/plugins/agentmail/search`); + expect(res.status).toBe(422); + }); + + it("rejects a draft with no recipient", async () => { + const res = await fetch(`${baseUrl}/api/plugins/agentmail/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ subject: "no recipients", text: "x" }) + }); + expect(res.status).toBe(422); + }); +}); diff --git a/apps/commandboard-api/src/agentmail.ts b/apps/commandboard-api/src/agentmail.ts new file mode 100644 index 0000000..55c414f --- /dev/null +++ b/apps/commandboard-api/src/agentmail.ts @@ -0,0 +1,283 @@ +// AgentMail host wiring for the CommandBoard API. The @logicsrc/plugin-agentmail +// package deliberately ships no network libraries — it defines the service, +// domain types, and a Mailu transport that expects injected IMAP/SMTP drivers. +// This module supplies those drivers (imapflow + nodemailer + mailparser) and +// builds the AgentMailService from the environment, pointing at the existing +// agentbbs Mailu server (mail.profullstack.com). When mail isn't configured it +// falls back to the in-memory transport so the API still boots and is testable. +// +// Env: +// AGENTMAIL_BACKEND "mailu" to use the real server; anything else = memory +// AGENTMAIL_MEMBER acting member handle (local-part), e.g. "chovy" +// AGENTMAIL_PAID "false" to disable the paid gate (default paid=true) +// AGENTMAIL_DOMAIN member address domain (default bbs.profullstack.com) +// AGENTMAIL_IMAP_HOST / _PORT / _SECURE +// AGENTMAIL_SMTP_HOST / _PORT / _SECURE / _TLS_SERVERNAME +// AGENTMAIL_USER / AGENTMAIL_PASS IMAP/SMTP credentials for the mailbox +import { ImapFlow, type FetchMessageObject } from "imapflow"; +import { simpleParser, type AddressObject } from "mailparser"; +import nodemailer from "nodemailer"; +import { + AgentMailService, + createMailuTransport, + InMemoryMailTransport, + resolveMailuConfig, + snippet, + type Draft, + type ImapDriver, + type MailAddress, + type Mailbox, + type MailIdentity, + type MailTransport, + type MailuConfig, + type Message, + type MessageSummary, + type SmtpDriver +} from "@logicsrc/plugin-agentmail"; + +const DEFAULT_DOMAIN = "bbs.profullstack.com"; + +// Acting identity. For now a single configured service member (consistent with +// the chovy@bbs.profullstack.com + plus-addressing decision); per-member auth is +// a follow-up. A request may override the handle via the x-agentmail-member +// header without changing which mailbox credentials are used. +export function mailIdentity(memberHeader?: string | null): MailIdentity { + const name = (memberHeader || process.env.AGENTMAIL_MEMBER || "chovy").trim(); + const paid = process.env.AGENTMAIL_PAID !== "false"; + return { name, paid }; +} + +// Builds the AgentMailService for a request. Uses the real Mailu transport when +// AGENTMAIL_BACKEND=mailu and credentials are present; otherwise an in-memory +// transport (dev/test) so routes are always exercisable. +export function buildAgentMailService(identity: MailIdentity): AgentMailService { + const domain = process.env.AGENTMAIL_DOMAIN ?? DEFAULT_DOMAIN; + const transport = resolveTransport(); + return new AgentMailService({ transport, identity, domain }); +} + +// A single in-memory transport shared across requests so the dev/test backend +// behaves like a real server (sent mail persists for later reads in-process). +let memoryTransport: InMemoryMailTransport | undefined; + +function resolveTransport(): MailTransport { + const user = process.env.AGENTMAIL_USER; + const pass = process.env.AGENTMAIL_PASS; + if (process.env.AGENTMAIL_BACKEND !== "mailu" || !user || !pass) { + memoryTransport ??= new InMemoryMailTransport(); + return memoryTransport; + } + const config = resolveMailuConfig({ user, pass }); + return createMailuTransport({ + config, + imap: createImapflowDriver(config), + smtp: createNodemailerDriver(config) + }); +} + +// --- IMAP driver (imapflow + mailparser) --- + +function createImapflowDriver(config: MailuConfig): ImapDriver { + const connect = () => + new ImapFlow({ + host: config.imap.host, + port: config.imap.port, + secure: config.imap.secure, + auth: { user: config.auth.user, pass: config.auth.pass }, + logger: false + }); + + // Each call opens a short-lived connection so the host stays stateless. + const withClient = async (fn: (c: ImapFlow) => Promise): Promise => { + const client = connect(); + await client.connect(); + try { + return await fn(client); + } finally { + await client.logout().catch(() => {}); + } + }; + + return { + async listMailboxes(): Promise { + return withClient(async (c) => { + const out: Mailbox[] = []; + for (const box of await c.list()) { + const status = await c.status(box.path, { messages: true, unseen: true }); + out.push({ + name: box.name, + path: box.path, + unseen: status.unseen ?? 0, + total: status.messages ?? 0 + }); + } + return out; + }); + }, + + async listMessages({ mailbox, limit = 50 }): Promise { + return withClient(async (c) => { + const lock = await c.getMailboxLock(mailbox); + try { + const status = await c.status(mailbox, { messages: true }); + const total = status.messages ?? 0; + if (total === 0) return []; + const start = Math.max(1, total - limit + 1); + const rows: MessageSummary[] = []; + for await (const msg of c.fetch(`${start}:*`, { uid: true, envelope: true, flags: true, internalDate: true })) { + rows.push(toSummary(msg, mailbox)); + } + return rows.reverse(); + } finally { + lock.release(); + } + }); + }, + + async readMessage(mailbox, uid): Promise { + return withClient(async (c) => { + const lock = await c.getMailboxLock(mailbox); + try { + const msg = await c.fetchOne(String(uid), { uid: true, envelope: true, flags: true, internalDate: true, source: true }, { uid: true }); + if (!msg || !msg.source) return null; + const parsed = await simpleParser(msg.source); + const summary = toSummary(msg, mailbox); + return { + ...summary, + snippet: snippet(parsed.text ?? summary.snippet), + cc: toAddresses(addrValues(parsed.cc)), + replyTo: addrValues(parsed.replyTo)[0] ? toAddress(addrValues(parsed.replyTo)[0]) : undefined, + messageId: parsed.messageId ?? "", + references: parsed.references ? [parsed.references].flat() : [], + text: parsed.text ?? "", + html: typeof parsed.html === "string" ? parsed.html : undefined, + attachments: (parsed.attachments ?? []).map((a) => ({ + filename: a.filename ?? "attachment", + contentType: a.contentType ?? "application/octet-stream", + size: a.size ?? 0 + })) + }; + } finally { + lock.release(); + } + }); + }, + + async search({ mailbox = "INBOX", query, limit = 50 }): Promise { + return withClient(async (c) => { + const lock = await c.getMailboxLock(mailbox); + try { + // imapflow OR across subject/from/body for a free-text query. + const uids = await c.search({ or: [{ subject: query }, { from: query }, { body: query }] }, { uid: true }); + if (!uids || uids.length === 0) return []; + const pick = uids.slice(-limit); + const rows: MessageSummary[] = []; + for await (const msg of c.fetch(pick, { uid: true, envelope: true, flags: true, internalDate: true }, { uid: true })) { + rows.push(toSummary(msg, mailbox)); + } + return rows.sort((a, b) => b.uid - a.uid); + } finally { + lock.release(); + } + }); + }, + + async setFlags(mailbox, uid, flags): Promise { + await withClient(async (c) => { + const lock = await c.getMailboxLock(mailbox); + try { + const add: string[] = []; + const remove: string[] = []; + if (flags.seen === true) add.push("\\Seen"); + if (flags.seen === false) remove.push("\\Seen"); + if (flags.flagged === true) add.push("\\Flagged"); + if (flags.flagged === false) remove.push("\\Flagged"); + if (add.length) await c.messageFlagsAdd({ uid: String(uid) }, add, { uid: true }); + if (remove.length) await c.messageFlagsRemove({ uid: String(uid) }, remove, { uid: true }); + } finally { + lock.release(); + } + }); + }, + + async deleteMessage(mailbox, uid): Promise { + await withClient(async (c) => { + const lock = await c.getMailboxLock(mailbox); + try { + await c.messageDelete({ uid: String(uid) }, { uid: true }); + } finally { + lock.release(); + } + }); + } + }; +} + +// --- SMTP driver (nodemailer) --- + +function createNodemailerDriver(config: MailuConfig): SmtpDriver { + const transport = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: { user: config.auth.user, pass: config.auth.pass }, + // Verify the cert against its real hostname even when dialing by IP/loopback. + tls: process.env.AGENTMAIL_SMTP_TLS_SERVERNAME ? { servername: process.env.AGENTMAIL_SMTP_TLS_SERVERNAME } : undefined + }); + return { + async send(from: string, draft: Draft) { + const info = await transport.sendMail({ + from, + to: draft.to.map(formatAddr), + cc: draft.cc?.map(formatAddr), + bcc: draft.bcc?.map(formatAddr), + subject: draft.subject, + text: draft.text, + html: draft.html, + inReplyTo: draft.inReplyTo, + references: draft.inReplyTo + }); + return { messageId: info.messageId }; + } + }; +} + +// --- mapping helpers --- + +// mailparser types an address header as AddressObject | AddressObject[]; flatten +// to the underlying address list regardless of shape. +function addrValues(a: AddressObject | AddressObject[] | undefined): { name?: string; address?: string }[] { + if (!a) return []; + return (Array.isArray(a) ? a : [a]).flatMap((x) => x.value); +} + +function toAddress(a: { name?: string; address?: string }): MailAddress { + return a.name ? { name: a.name, address: a.address ?? "" } : { address: a.address ?? "" }; +} + +function toAddresses(list: { name?: string; address?: string }[] | undefined): MailAddress[] { + return (list ?? []).filter((a) => a.address).map(toAddress); +} + +function toSummary(msg: FetchMessageObject, mailbox: string): MessageSummary { + const env = msg.envelope; + const flags = msg.flags ?? new Set(); + const date = env?.date ?? msg.internalDate ?? new Date(); + const from = toAddresses(env?.from)[0] ?? { address: "" }; + return { + uid: msg.uid, + mailbox, + from, + to: toAddresses(env?.to), + subject: env?.subject ?? "", + date: new Date(date).toISOString(), + seen: flags.has("\\Seen"), + flagged: flags.has("\\Flagged"), + hasAttachments: false, + snippet: "" + }; +} + +function formatAddr(a: MailAddress): string { + return a.name ? `${a.name} <${a.address}>` : a.address; +} diff --git a/apps/commandboard-api/src/contract.test.ts b/apps/commandboard-api/src/contract.test.ts index 489ad69..b87b94d 100644 --- a/apps/commandboard-api/src/contract.test.ts +++ b/apps/commandboard-api/src/contract.test.ts @@ -68,7 +68,7 @@ describe("CommandBoard API contracts", () => { }; expect(response.status).toBe(200); - expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute", "feed-discovery", "social-accounts", "email-accounts"]); + expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute", "feed-discovery", "social-accounts", "email-accounts", "agentmail"]); expect(body.plugins.find((plugin) => plugin.id === "sh1pt")).toMatchObject({ enabled: true, capabilities: expect.arrayContaining(["projects.sync", "actions.publish", "deployments.status"]) diff --git a/apps/commandboard-api/src/index.ts b/apps/commandboard-api/src/index.ts index 2666202..809c798 100644 --- a/apps/commandboard-api/src/index.ts +++ b/apps/commandboard-api/src/index.ts @@ -1,6 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { pathToFileURL } from "node:url"; import { createPluginRegistry } from "@logicsrc/plugin-core"; +import { agentMailPlugin, DraftError, MailAccessError, parseAddress, type Draft, type MailAddress } from "@logicsrc/plugin-agentmail"; import { c0mputePlugin } from "@logicsrc/plugin-c0mpute"; import { coinPayPlugin } from "@logicsrc/plugin-coinpay"; import { emailAccountsPlugin, listEmailAccountProviders } from "@logicsrc/plugin-email-accounts"; @@ -9,8 +10,9 @@ import { sh1ptPlugin } from "@logicsrc/plugin-sh1pt"; import { listSocialAccountProviders, socialAccountsPlugin } from "@logicsrc/plugin-social-accounts"; import { uGigPlugin } from "@logicsrc/plugin-ugig"; import { schemas, validate } from "@logicsrc/validators"; +import { buildAgentMailService, mailIdentity } from "./agentmail.js"; -const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin]); +const registry = createPluginRegistry([coinPayPlugin, uGigPlugin, sh1ptPlugin, c0mputePlugin, feedDiscoveryPlugin, socialAccountsPlugin, emailAccountsPlugin, agentMailPlugin]); const boards = [ { path: "/general", title: "General", description: "CommandBoard.run general discussion." }, @@ -83,7 +85,7 @@ async function route(request: IncomingMessage, response: ServerResponse) { json(response, 200, { ok: true, service: "commandboard-api", - endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"] + endpoints: ["/health", "/api/boards", "/api/tasks", "/api/plugins", "/api/schemas", "/api/accounts/providers", "/api/accounts", "/api/social/providers", "/api/email/providers", "/api/feeds/discover", "/api/feeds/providers", "/api/plugins/agentmail/mailboxes", "/api/plugins/agentmail/mailboxes/:mailbox/messages", "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", "/api/plugins/agentmail/search", "/api/plugins/agentmail/messages", "/rss/discover/:keyword.xml", "/opml/discover/:keyword.xml", "/atom/discover/:keyword.xml", "/json-feed/discover/:keyword.json"] }); return; } @@ -218,6 +220,11 @@ async function route(request: IncomingMessage, response: ServerResponse) { return; } + if (url.pathname === "/api/plugins/agentmail" || url.pathname.startsWith("/api/plugins/agentmail/")) { + await handleAgentMail(request, response, url); + return; + } + if (request.method === "GET" && url.pathname === "/api/plugins/sh1pt/projects") { json(response, 200, { projects: sh1ptProjects }); return; @@ -318,6 +325,147 @@ function json(response: ServerResponse, status: number, data: unknown) { response.end(JSON.stringify(data, null, 2)); } +// AgentMail routes (the @logicsrc/plugin-agentmail surface), backed by the +// agentbbs Mailu server. The acting member comes from the x-agentmail-member +// header (falling back to the configured service identity). Access/draft errors +// map to 402/422 instead of the generic 500 the outer handler would produce. +const AGENTMAIL_PREFIX = "/api/plugins/agentmail"; + +async function handleAgentMail(request: IncomingMessage, response: ServerResponse, url: URL) { + const memberHeader = request.headers["x-agentmail-member"]; + const member = Array.isArray(memberHeader) ? memberHeader[0] : memberHeader; + const service = buildAgentMailService(mailIdentity(member)); + const sub = url.pathname.slice(AGENTMAIL_PREFIX.length); // e.g. "/mailboxes/INBOX/messages" + const method = request.method ?? "GET"; + + try { + if (method === "GET" && (sub === "" || sub === "/" || sub === "/mailboxes")) { + json(response, 200, { address: service.address(), mailboxes: await service.mailboxes() }); + return; + } + + if (method === "GET" && sub === "/search") { + const query = url.searchParams.get("q") ?? url.searchParams.get("query"); + if (!query) { + json(response, 422, { error: "Expected ?q=" }); + return; + } + const mailbox = url.searchParams.get("mailbox") ?? undefined; + const limit = numberParam(url.searchParams.get("limit")); + json(response, 200, { messages: await service.search(query, { mailbox, limit }) }); + return; + } + + if (method === "POST" && sub === "/messages") { + let body: unknown; + try { + body = await readJson(request); + } catch { + json(response, 400, { error: "Invalid JSON body" }); + return; + } + if (!isRecord(body)) { + json(response, 422, { error: "Draft body must be an object" }); + return; + } + const draft: Draft = { + to: coerceAddresses(body.to), + cc: body.cc !== undefined ? coerceAddresses(body.cc) : undefined, + bcc: body.bcc !== undefined ? coerceAddresses(body.bcc) : undefined, + subject: typeof body.subject === "string" ? body.subject : "", + text: typeof body.text === "string" ? body.text : "", + html: typeof body.html === "string" ? body.html : undefined, + inReplyTo: typeof body.inReplyTo === "string" ? body.inReplyTo : undefined + }; + json(response, 201, await service.send(draft)); + return; + } + + // /mailboxes/{mailbox}/messages[/{uid}] + const msgMatch = /^\/mailboxes\/([^/]+)\/messages(?:\/(\d+))?$/.exec(sub); + if (msgMatch) { + const mailbox = decodeURIComponent(msgMatch[1]); + const uid = msgMatch[2] ? Number(msgMatch[2]) : undefined; + + if (uid === undefined) { + if (method !== "GET") { + json(response, 405, { error: "Method not allowed" }); + return; + } + const limit = numberParam(url.searchParams.get("limit")); + json(response, 200, { mailbox, messages: await service.list(mailbox, limit) }); + return; + } + + if (method === "GET") { + const peek = url.searchParams.get("peek") === "true"; + const message = await service.read(mailbox, uid, { peek }); + if (!message) { + json(response, 404, { error: `no message uid ${uid} in ${mailbox}` }); + return; + } + json(response, 200, { message }); + return; + } + + if (method === "PATCH") { + let body: unknown; + try { + body = await readJson(request); + } catch { + json(response, 400, { error: "Invalid JSON body" }); + return; + } + if (!isRecord(body)) { + json(response, 422, { error: "Expected { seen?, flagged? }" }); + return; + } + await service.setFlags(mailbox, uid, { + seen: typeof body.seen === "boolean" ? body.seen : undefined, + flagged: typeof body.flagged === "boolean" ? body.flagged : undefined + }); + json(response, 200, { ok: true }); + return; + } + + if (method === "DELETE") { + await service.delete(mailbox, uid); + json(response, 200, { ok: true }); + return; + } + + json(response, 405, { error: "Method not allowed" }); + return; + } + + json(response, 404, { error: "Not found" }); + } catch (error) { + if (error instanceof MailAccessError) { + json(response, 402, { error: error.message }); + return; + } + if (error instanceof DraftError) { + json(response, 422, { error: error.message }); + return; + } + json(response, 502, { error: error instanceof Error ? error.message : String(error) }); + } +} + +// Accepts a recipient field as a string, "Name ", an array of those, or +// {name?,address} objects, and normalizes to MailAddress[]. +function coerceAddresses(input: unknown): MailAddress[] { + const one = (v: unknown): MailAddress | null => { + if (typeof v === "string") return parseAddress(v); + if (isRecord(v) && typeof v.address === "string") { + return typeof v.name === "string" ? { name: v.name, address: v.address } : { address: v.address }; + } + return null; + }; + const list = Array.isArray(input) ? input : input === undefined || input === null ? [] : [input]; + return list.map(one).filter((a): a is MailAddress => a !== null); +} + function text(response: ServerResponse, status: number, contentType: string, body: string) { response.writeHead(status, { "content-type": contentType }); response.end(body);