Skip to content

Commit f27210a

Browse files
ralyodioclaude
andauthored
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>
1 parent 152cc44 commit f27210a

5 files changed

Lines changed: 520 additions & 4 deletions

File tree

apps/commandboard-api/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,22 @@
1313
},
1414
"dependencies": {
1515
"@logicsrc/plugin-core": "file:../../packages/plugin-core",
16+
"@logicsrc/plugin-agentmail": "file:../../plugins/agentmail",
1617
"@logicsrc/plugin-c0mpute": "file:../../plugins/c0mpute",
1718
"@logicsrc/plugin-coinpay": "file:../../plugins/coinpay",
1819
"@logicsrc/plugin-email-accounts": "file:../../plugins/email-accounts",
1920
"@logicsrc/plugin-feed-discovery": "file:../../plugins/feed-discovery",
2021
"@logicsrc/plugin-sh1pt": "file:../../plugins/sh1pt",
2122
"@logicsrc/plugin-social-accounts": "file:../../plugins/social-accounts",
2223
"@logicsrc/plugin-ugig": "file:../../plugins/ugig",
23-
"@logicsrc/validators": "file:../../packages/validators"
24+
"@logicsrc/validators": "file:../../packages/validators",
25+
"imapflow": "^1.4.3",
26+
"mailparser": "^3.9.12",
27+
"nodemailer": "^9.0.1"
2428
},
2529
"devDependencies": {
30+
"@types/mailparser": "^3.4.6",
31+
"@types/nodemailer": "^8.0.1",
2632
"tsx": "^4.21.0",
2733
"vitest": "^4.0.8"
2834
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { Server } from "node:http";
2+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
3+
import { createCommandBoardServer } from "./index.js";
4+
5+
// These exercise the AgentMail routes against the default in-memory transport
6+
// (no AGENTMAIL_BACKEND=mailu), so no live mail server is required.
7+
let server: Server;
8+
let baseUrl: string;
9+
10+
beforeAll(async () => {
11+
server = createCommandBoardServer();
12+
await new Promise<void>((resolve) => server.listen(0, resolve));
13+
const address = server.address();
14+
if (!address || typeof address === "string") {
15+
throw new Error("Expected API server to bind to a local port");
16+
}
17+
baseUrl = `http://127.0.0.1:${address.port}`;
18+
});
19+
20+
afterAll(async () => {
21+
await new Promise<void>((resolve, reject) => {
22+
server.close((error) => (error ? reject(error) : resolve()));
23+
});
24+
});
25+
26+
describe("AgentMail API routes", () => {
27+
it("lists mailboxes and exposes the member address", async () => {
28+
const res = await fetch(`${baseUrl}/api/plugins/agentmail/mailboxes`);
29+
expect(res.status).toBe(200);
30+
const body = (await res.json()) as { address: string; mailboxes: unknown[] };
31+
expect(body.address).toBe("chovy@bbs.profullstack.com");
32+
expect(Array.isArray(body.mailboxes)).toBe(true);
33+
});
34+
35+
it("registers agentmail in the plugin snapshot", async () => {
36+
const res = await fetch(`${baseUrl}/api/plugins`);
37+
const body = (await res.json()) as { plugins: { id: string }[] };
38+
expect(body.plugins.some((p) => p.id === "agentmail")).toBe(true);
39+
});
40+
41+
it("sends a draft and stores it in Sent", async () => {
42+
const send = await fetch(`${baseUrl}/api/plugins/agentmail/messages`, {
43+
method: "POST",
44+
headers: { "content-type": "application/json" },
45+
body: JSON.stringify({ to: "qa@example.com", subject: "hi", text: "from a QA run" })
46+
});
47+
expect(send.status).toBe(201);
48+
const result = (await send.json()) as { messageId: string };
49+
expect(result.messageId).toMatch(/@/);
50+
51+
const sent = await fetch(`${baseUrl}/api/plugins/agentmail/mailboxes/Sent/messages`);
52+
expect(sent.status).toBe(200);
53+
const body = (await sent.json()) as { messages: { subject: string }[] };
54+
expect(body.messages.some((m) => m.subject === "hi")).toBe(true);
55+
});
56+
57+
it("accepts a 'Name <addr>' recipient string", async () => {
58+
const send = await fetch(`${baseUrl}/api/plugins/agentmail/messages`, {
59+
method: "POST",
60+
headers: { "content-type": "application/json" },
61+
body: JSON.stringify({ to: "QA Bot <qa2@example.com>", subject: "named", text: "x" })
62+
});
63+
expect(send.status).toBe(201);
64+
});
65+
66+
it("rejects a search without a query", async () => {
67+
const res = await fetch(`${baseUrl}/api/plugins/agentmail/search`);
68+
expect(res.status).toBe(422);
69+
});
70+
71+
it("rejects a draft with no recipient", async () => {
72+
const res = await fetch(`${baseUrl}/api/plugins/agentmail/messages`, {
73+
method: "POST",
74+
headers: { "content-type": "application/json" },
75+
body: JSON.stringify({ subject: "no recipients", text: "x" })
76+
});
77+
expect(res.status).toBe(422);
78+
});
79+
});
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
// AgentMail host wiring for the CommandBoard API. The @logicsrc/plugin-agentmail
2+
// package deliberately ships no network libraries — it defines the service,
3+
// domain types, and a Mailu transport that expects injected IMAP/SMTP drivers.
4+
// This module supplies those drivers (imapflow + nodemailer + mailparser) and
5+
// builds the AgentMailService from the environment, pointing at the existing
6+
// agentbbs Mailu server (mail.profullstack.com). When mail isn't configured it
7+
// falls back to the in-memory transport so the API still boots and is testable.
8+
//
9+
// Env:
10+
// AGENTMAIL_BACKEND "mailu" to use the real server; anything else = memory
11+
// AGENTMAIL_MEMBER acting member handle (local-part), e.g. "chovy"
12+
// AGENTMAIL_PAID "false" to disable the paid gate (default paid=true)
13+
// AGENTMAIL_DOMAIN member address domain (default bbs.profullstack.com)
14+
// AGENTMAIL_IMAP_HOST / _PORT / _SECURE
15+
// AGENTMAIL_SMTP_HOST / _PORT / _SECURE / _TLS_SERVERNAME
16+
// AGENTMAIL_USER / AGENTMAIL_PASS IMAP/SMTP credentials for the mailbox
17+
import { ImapFlow, type FetchMessageObject } from "imapflow";
18+
import { simpleParser, type AddressObject } from "mailparser";
19+
import nodemailer from "nodemailer";
20+
import {
21+
AgentMailService,
22+
createMailuTransport,
23+
InMemoryMailTransport,
24+
resolveMailuConfig,
25+
snippet,
26+
type Draft,
27+
type ImapDriver,
28+
type MailAddress,
29+
type Mailbox,
30+
type MailIdentity,
31+
type MailTransport,
32+
type MailuConfig,
33+
type Message,
34+
type MessageSummary,
35+
type SmtpDriver
36+
} from "@logicsrc/plugin-agentmail";
37+
38+
const DEFAULT_DOMAIN = "bbs.profullstack.com";
39+
40+
// Acting identity. For now a single configured service member (consistent with
41+
// the chovy@bbs.profullstack.com + plus-addressing decision); per-member auth is
42+
// a follow-up. A request may override the handle via the x-agentmail-member
43+
// header without changing which mailbox credentials are used.
44+
export function mailIdentity(memberHeader?: string | null): MailIdentity {
45+
const name = (memberHeader || process.env.AGENTMAIL_MEMBER || "chovy").trim();
46+
const paid = process.env.AGENTMAIL_PAID !== "false";
47+
return { name, paid };
48+
}
49+
50+
// Builds the AgentMailService for a request. Uses the real Mailu transport when
51+
// AGENTMAIL_BACKEND=mailu and credentials are present; otherwise an in-memory
52+
// transport (dev/test) so routes are always exercisable.
53+
export function buildAgentMailService(identity: MailIdentity): AgentMailService {
54+
const domain = process.env.AGENTMAIL_DOMAIN ?? DEFAULT_DOMAIN;
55+
const transport = resolveTransport();
56+
return new AgentMailService({ transport, identity, domain });
57+
}
58+
59+
// A single in-memory transport shared across requests so the dev/test backend
60+
// behaves like a real server (sent mail persists for later reads in-process).
61+
let memoryTransport: InMemoryMailTransport | undefined;
62+
63+
function resolveTransport(): MailTransport {
64+
const user = process.env.AGENTMAIL_USER;
65+
const pass = process.env.AGENTMAIL_PASS;
66+
if (process.env.AGENTMAIL_BACKEND !== "mailu" || !user || !pass) {
67+
memoryTransport ??= new InMemoryMailTransport();
68+
return memoryTransport;
69+
}
70+
const config = resolveMailuConfig({ user, pass });
71+
return createMailuTransport({
72+
config,
73+
imap: createImapflowDriver(config),
74+
smtp: createNodemailerDriver(config)
75+
});
76+
}
77+
78+
// --- IMAP driver (imapflow + mailparser) ---
79+
80+
function createImapflowDriver(config: MailuConfig): ImapDriver {
81+
const connect = () =>
82+
new ImapFlow({
83+
host: config.imap.host,
84+
port: config.imap.port,
85+
secure: config.imap.secure,
86+
auth: { user: config.auth.user, pass: config.auth.pass },
87+
logger: false
88+
});
89+
90+
// Each call opens a short-lived connection so the host stays stateless.
91+
const withClient = async <T>(fn: (c: ImapFlow) => Promise<T>): Promise<T> => {
92+
const client = connect();
93+
await client.connect();
94+
try {
95+
return await fn(client);
96+
} finally {
97+
await client.logout().catch(() => {});
98+
}
99+
};
100+
101+
return {
102+
async listMailboxes(): Promise<Mailbox[]> {
103+
return withClient(async (c) => {
104+
const out: Mailbox[] = [];
105+
for (const box of await c.list()) {
106+
const status = await c.status(box.path, { messages: true, unseen: true });
107+
out.push({
108+
name: box.name,
109+
path: box.path,
110+
unseen: status.unseen ?? 0,
111+
total: status.messages ?? 0
112+
});
113+
}
114+
return out;
115+
});
116+
},
117+
118+
async listMessages({ mailbox, limit = 50 }): Promise<MessageSummary[]> {
119+
return withClient(async (c) => {
120+
const lock = await c.getMailboxLock(mailbox);
121+
try {
122+
const status = await c.status(mailbox, { messages: true });
123+
const total = status.messages ?? 0;
124+
if (total === 0) return [];
125+
const start = Math.max(1, total - limit + 1);
126+
const rows: MessageSummary[] = [];
127+
for await (const msg of c.fetch(`${start}:*`, { uid: true, envelope: true, flags: true, internalDate: true })) {
128+
rows.push(toSummary(msg, mailbox));
129+
}
130+
return rows.reverse();
131+
} finally {
132+
lock.release();
133+
}
134+
});
135+
},
136+
137+
async readMessage(mailbox, uid): Promise<Message | null> {
138+
return withClient(async (c) => {
139+
const lock = await c.getMailboxLock(mailbox);
140+
try {
141+
const msg = await c.fetchOne(String(uid), { uid: true, envelope: true, flags: true, internalDate: true, source: true }, { uid: true });
142+
if (!msg || !msg.source) return null;
143+
const parsed = await simpleParser(msg.source);
144+
const summary = toSummary(msg, mailbox);
145+
return {
146+
...summary,
147+
snippet: snippet(parsed.text ?? summary.snippet),
148+
cc: toAddresses(addrValues(parsed.cc)),
149+
replyTo: addrValues(parsed.replyTo)[0] ? toAddress(addrValues(parsed.replyTo)[0]) : undefined,
150+
messageId: parsed.messageId ?? "",
151+
references: parsed.references ? [parsed.references].flat() : [],
152+
text: parsed.text ?? "",
153+
html: typeof parsed.html === "string" ? parsed.html : undefined,
154+
attachments: (parsed.attachments ?? []).map((a) => ({
155+
filename: a.filename ?? "attachment",
156+
contentType: a.contentType ?? "application/octet-stream",
157+
size: a.size ?? 0
158+
}))
159+
};
160+
} finally {
161+
lock.release();
162+
}
163+
});
164+
},
165+
166+
async search({ mailbox = "INBOX", query, limit = 50 }): Promise<MessageSummary[]> {
167+
return withClient(async (c) => {
168+
const lock = await c.getMailboxLock(mailbox);
169+
try {
170+
// imapflow OR across subject/from/body for a free-text query.
171+
const uids = await c.search({ or: [{ subject: query }, { from: query }, { body: query }] }, { uid: true });
172+
if (!uids || uids.length === 0) return [];
173+
const pick = uids.slice(-limit);
174+
const rows: MessageSummary[] = [];
175+
for await (const msg of c.fetch(pick, { uid: true, envelope: true, flags: true, internalDate: true }, { uid: true })) {
176+
rows.push(toSummary(msg, mailbox));
177+
}
178+
return rows.sort((a, b) => b.uid - a.uid);
179+
} finally {
180+
lock.release();
181+
}
182+
});
183+
},
184+
185+
async setFlags(mailbox, uid, flags): Promise<void> {
186+
await withClient(async (c) => {
187+
const lock = await c.getMailboxLock(mailbox);
188+
try {
189+
const add: string[] = [];
190+
const remove: string[] = [];
191+
if (flags.seen === true) add.push("\\Seen");
192+
if (flags.seen === false) remove.push("\\Seen");
193+
if (flags.flagged === true) add.push("\\Flagged");
194+
if (flags.flagged === false) remove.push("\\Flagged");
195+
if (add.length) await c.messageFlagsAdd({ uid: String(uid) }, add, { uid: true });
196+
if (remove.length) await c.messageFlagsRemove({ uid: String(uid) }, remove, { uid: true });
197+
} finally {
198+
lock.release();
199+
}
200+
});
201+
},
202+
203+
async deleteMessage(mailbox, uid): Promise<void> {
204+
await withClient(async (c) => {
205+
const lock = await c.getMailboxLock(mailbox);
206+
try {
207+
await c.messageDelete({ uid: String(uid) }, { uid: true });
208+
} finally {
209+
lock.release();
210+
}
211+
});
212+
}
213+
};
214+
}
215+
216+
// --- SMTP driver (nodemailer) ---
217+
218+
function createNodemailerDriver(config: MailuConfig): SmtpDriver {
219+
const transport = nodemailer.createTransport({
220+
host: config.smtp.host,
221+
port: config.smtp.port,
222+
secure: config.smtp.secure,
223+
auth: { user: config.auth.user, pass: config.auth.pass },
224+
// Verify the cert against its real hostname even when dialing by IP/loopback.
225+
tls: process.env.AGENTMAIL_SMTP_TLS_SERVERNAME ? { servername: process.env.AGENTMAIL_SMTP_TLS_SERVERNAME } : undefined
226+
});
227+
return {
228+
async send(from: string, draft: Draft) {
229+
const info = await transport.sendMail({
230+
from,
231+
to: draft.to.map(formatAddr),
232+
cc: draft.cc?.map(formatAddr),
233+
bcc: draft.bcc?.map(formatAddr),
234+
subject: draft.subject,
235+
text: draft.text,
236+
html: draft.html,
237+
inReplyTo: draft.inReplyTo,
238+
references: draft.inReplyTo
239+
});
240+
return { messageId: info.messageId };
241+
}
242+
};
243+
}
244+
245+
// --- mapping helpers ---
246+
247+
// mailparser types an address header as AddressObject | AddressObject[]; flatten
248+
// to the underlying address list regardless of shape.
249+
function addrValues(a: AddressObject | AddressObject[] | undefined): { name?: string; address?: string }[] {
250+
if (!a) return [];
251+
return (Array.isArray(a) ? a : [a]).flatMap((x) => x.value);
252+
}
253+
254+
function toAddress(a: { name?: string; address?: string }): MailAddress {
255+
return a.name ? { name: a.name, address: a.address ?? "" } : { address: a.address ?? "" };
256+
}
257+
258+
function toAddresses(list: { name?: string; address?: string }[] | undefined): MailAddress[] {
259+
return (list ?? []).filter((a) => a.address).map(toAddress);
260+
}
261+
262+
function toSummary(msg: FetchMessageObject, mailbox: string): MessageSummary {
263+
const env = msg.envelope;
264+
const flags = msg.flags ?? new Set<string>();
265+
const date = env?.date ?? msg.internalDate ?? new Date();
266+
const from = toAddresses(env?.from)[0] ?? { address: "" };
267+
return {
268+
uid: msg.uid,
269+
mailbox,
270+
from,
271+
to: toAddresses(env?.to),
272+
subject: env?.subject ?? "",
273+
date: new Date(date).toISOString(),
274+
seen: flags.has("\\Seen"),
275+
flagged: flags.has("\\Flagged"),
276+
hasAttachments: false,
277+
snippet: ""
278+
};
279+
}
280+
281+
function formatAddr(a: MailAddress): string {
282+
return a.name ? `${a.name} <${a.address}>` : a.address;
283+
}

apps/commandboard-api/src/contract.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe("CommandBoard API contracts", () => {
6868
};
6969

7070
expect(response.status).toBe(200);
71-
expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute", "feed-discovery", "social-accounts", "email-accounts"]);
71+
expect(body.plugins.map((plugin) => plugin.id)).toEqual(["coinpay", "ugig", "sh1pt", "c0mpute", "feed-discovery", "social-accounts", "email-accounts", "agentmail"]);
7272
expect(body.plugins.find((plugin) => plugin.id === "sh1pt")).toMatchObject({
7373
enabled: true,
7474
capabilities: expect.arrayContaining(["projects.sync", "actions.publish", "deployments.status"])

0 commit comments

Comments
 (0)