mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
feat(commandboard-api): expose AgentMail (agentbbs mailbox) routes (#86)
Mount @logicsrc/plugin-agentmail in the CommandBoard API so members can list/read/search/send mail over the existing agentbbs Mailu server. - src/agentmail.ts: inject imapflow + nodemailer + mailparser drivers into the plugin's Mailu transport (kept out of the plugin by design); env-driven builder (AGENTMAIL_BACKEND=mailu → bbs Mailu, else in-memory singleton for dev/test). Handles STARTTLS cert-vs-loopback via AGENTMAIL_SMTP_TLS_SERVERNAME. - index.ts: register agentMailPlugin + 7 routes under /api/plugins/agentmail/* (mailboxes, list, read, search, send, PATCH flags, delete). Acting member from x-agentmail-member header (else AGENTMAIL_MEMBER), paid-gated; MailAccessError->402, DraftError->422. - 6 route tests (in-memory backend) + contract test plugin-id list updated. commandboard-api 20/20, builds clean. Note: run `npm install` to refresh the lockfile for the 3 new deps. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
152cc444ae
commit
f27210a192
5 changed files with 520 additions and 4 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
79
apps/commandboard-api/src/agentmail.test.ts
Normal file
79
apps/commandboard-api/src/agentmail.test.ts
Normal file
|
|
@ -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<void>((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<void>((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 <addr>' 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 <qa2@example.com>", 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);
|
||||
});
|
||||
});
|
||||
283
apps/commandboard-api/src/agentmail.ts
Normal file
283
apps/commandboard-api/src/agentmail.ts
Normal file
|
|
@ -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 <T>(fn: (c: ImapFlow) => Promise<T>): Promise<T> => {
|
||||
const client = connect();
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async listMailboxes(): Promise<Mailbox[]> {
|
||||
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<MessageSummary[]> {
|
||||
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<Message | null> {
|
||||
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<MessageSummary[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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 <addr>", 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue