diff --git a/package.json b/package.json index 8d7dd52..fe49683 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "apps/*" ], "scripts": { - "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", + "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "start": "npm --workspace @logicsrc/web run start", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", diff --git a/plugins/agentmail/README.md b/plugins/agentmail/README.md new file mode 100644 index 0000000..9a85904 --- /dev/null +++ b/plugins/agentmail/README.md @@ -0,0 +1,62 @@ +# @logicsrc/plugin-agentmail + +Agent-native mail for LogicSRC: a paid-member mailbox client (read, search, +compose, send, flag, delete) that works the same for **humans** (a TUI) and +**bots/agents** (the CLI, MCP, or direct service calls), because every method +returns plain JSON-serializable domain objects. + +## Design + +The plugin follows the LogicSRC plugin pattern (cf. `agentgit`): + +- **`domain.ts`** — transport-agnostic types (`Mailbox`, `MessageSummary`, + `Message`, `Draft`, `MailAddress`) and pure helpers (`parseAddress`, + `formatAddress`, `normalizeDraft`, `isValidEmail`, `snippet`). +- **`ports.ts`** — the `MailTransport` seam. The service talks only to this. +- **`service.ts`** — `AgentMailService`: paid-gated, ergonomic operations + (`inbox`, `list`, `read`, `search`, `send`, `reply`, `flag`, `delete`). +- **`access.ts`** — mail is a **Founding Lifetime Member** (paid) perk; every + call runs `assertPaid`. +- **`transports/memory.ts`** — `InMemoryMailTransport`: a complete, dependency + free backend for tests, local dev, and as the reference implementation. +- **`transports/mailu.ts`** — the self-hosted Mailu seam + (`mail.profullstack.com` IMAP + `smtp.profullstack.com` submission). It takes + **injected** IMAP/SMTP drivers so heavy network libraries stay out of the + plugin; a consuming app supplies the concrete drivers (e.g. `imapflow` + + `nodemailer` in Node, or the Go client on the BBS). + +## Usage + +```ts +import { AgentMailService, InMemoryMailTransport } from "@logicsrc/plugin-agentmail"; + +const transport = new InMemoryMailTransport(); +const mail = new AgentMailService({ + transport, + identity: { name: "alice", paid: true }, + domain: "mail.profullstack.com" +}); + +await mail.send({ to: [{ address: "carol@example.com" }], subject: "Hi", text: "hello" }); +const inbox = await mail.inbox(); +``` + +Against the real stack: + +```ts +import { AgentMailService, createMailuTransport, resolveMailuConfig } from "@logicsrc/plugin-agentmail"; + +const config = resolveMailuConfig({ user: "alice", pass: process.env.MAIL_PASS! }); +const transport = createMailuTransport({ config, imap, smtp }); // imap/smtp: app-provided drivers +const mail = new AgentMailService({ transport, identity: { name: "alice", paid: true }, domain: config.domain }); +``` + +## Config / env + +| Var | Default | Meaning | +|---|---|---| +| `AGENTMAIL_DOMAIN` | `mail.profullstack.com` | member address domain | +| `AGENTMAIL_IMAP_HOST` | `mail.profullstack.com` | IMAP host | +| `AGENTMAIL_IMAP_PORT` | `993` | IMAPS port | +| `AGENTMAIL_SMTP_HOST` | `smtp.profullstack.com` | SMTP submission host | +| `AGENTMAIL_SMTP_PORT` | `587` | submission port (STARTTLS) | diff --git a/plugins/agentmail/package.json b/plugins/agentmail/package.json new file mode 100644 index 0000000..80c9f75 --- /dev/null +++ b/plugins/agentmail/package.json @@ -0,0 +1,18 @@ +{ + "name": "@logicsrc/plugin-agentmail", + "version": "0.1.0", + "description": "Agent-native mail: a paid-member mailbox client (read/search/compose/send) over an injected IMAP/SMTP transport, designed for both bots and humans.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run src --passWithNoTests" + }, + "dependencies": { + "@logicsrc/plugin-core": "file:../../packages/plugin-core" + }, + "devDependencies": { + "vitest": "^4.0.8" + } +} diff --git a/plugins/agentmail/src/access.ts b/plugins/agentmail/src/access.ts new file mode 100644 index 0000000..fe7e207 --- /dev/null +++ b/plugins/agentmail/src/access.ts @@ -0,0 +1,40 @@ +// Access control for AgentMail. Mail is a Founding Lifetime Member (paid) perk, +// so every service call is gated on a paid identity. Capability constants keep +// route/manifest wiring and runtime checks in sync. + +export const AGENTMAIL_CAPABILITIES = { + mailboxList: "mailbox.list", + messageList: "message.list", + messageRead: "message.read", + messageSearch: "message.search", + messageCompose: "message.compose", + messageSend: "message.send", + messageFlag: "message.flag", + messageDelete: "message.delete", + accessGate: "access.gate" +} as const; + +export type AgentMailCapability = (typeof AGENTMAIL_CAPABILITIES)[keyof typeof AGENTMAIL_CAPABILITIES]; + +/** Who is acting: a BBS member name and whether they hold the paid membership. */ +export interface MailIdentity { + /** The member's local-part / handle, e.g. "alice". */ + name: string; + /** True for a Founding Lifetime Member; mail is gated on this. */ + paid: boolean; +} + +/** Raised when a non-paid (or missing) identity attempts a mail action. */ +export class MailAccessError extends Error { + constructor(message = "AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com") { + super(message); + this.name = "MailAccessError"; + } +} + +/** Throws MailAccessError unless the identity is a paid member. */ +export function assertPaid(identity: MailIdentity): void { + if (!identity || !identity.name || !identity.paid) { + throw new MailAccessError(); + } +} diff --git a/plugins/agentmail/src/domain.test.ts b/plugins/agentmail/src/domain.test.ts new file mode 100644 index 0000000..d29222e --- /dev/null +++ b/plugins/agentmail/src/domain.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { DraftError, formatAddress, isValidEmail, mailboxAddress, normalizeDraft, parseAddress, snippet } from "./domain.js"; + +describe("isValidEmail", () => { + it("accepts normal addresses", () => { + expect(isValidEmail("alice@mail.profullstack.com")).toBe(true); + }); + it("rejects malformed addresses", () => { + for (const bad of ["", "alice", "alice@", "@host.com", "a b@host.com", "alice@host"]) { + expect(isValidEmail(bad)).toBe(false); + } + }); +}); + +describe("mailboxAddress", () => { + it("joins local part and domain", () => { + expect(mailboxAddress("alice", "mail.profullstack.com")).toBe("alice@mail.profullstack.com"); + }); +}); + +describe("parseAddress / formatAddress", () => { + it("parses a named address", () => { + expect(parseAddress("Ada Lovelace ")).toEqual({ + name: "Ada Lovelace", + address: "ada@mail.profullstack.com" + }); + }); + it("parses a bare address", () => { + expect(parseAddress("ada@mail.profullstack.com")).toEqual({ address: "ada@mail.profullstack.com" }); + }); + it("round-trips and quotes names with specials", () => { + expect(formatAddress({ name: "Ada", address: "ada@x.com" })).toBe("Ada "); + expect(formatAddress({ address: "ada@x.com" })).toBe("ada@x.com"); + expect(formatAddress({ name: "Doe, John", address: "j@x.com" })).toBe('"Doe, John" '); + }); +}); + +describe("snippet", () => { + it("collapses whitespace and truncates", () => { + expect(snippet("hello world\n\nthere")).toBe("hello world there"); + expect(snippet("abcdef", 4)).toBe("abc…"); + }); +}); + +describe("normalizeDraft", () => { + it("trims subject, drops empty cc/bcc, keeps valid recipients", () => { + const d = normalizeDraft({ + to: [{ address: " ada@x.com " }], + cc: [], + subject: " hi ", + text: "yo" + }); + expect(d.to).toEqual([{ address: "ada@x.com" }]); + expect(d.subject).toBe("hi"); + expect(d.cc).toBeUndefined(); + }); + it("rejects a draft with no recipients", () => { + expect(() => normalizeDraft({ to: [], subject: "x", text: "y" })).toThrow(DraftError); + }); + it("rejects an invalid recipient", () => { + expect(() => normalizeDraft({ to: [{ address: "nope" }], subject: "x", text: "y" })).toThrow(/invalid recipient/); + }); +}); diff --git a/plugins/agentmail/src/domain.ts b/plugins/agentmail/src/domain.ts new file mode 100644 index 0000000..910c450 --- /dev/null +++ b/plugins/agentmail/src/domain.ts @@ -0,0 +1,143 @@ +// Domain types and pure helpers for AgentMail. Everything here is +// transport-agnostic and JSON-serializable, so the same shapes serve a human +// TUI, a bot/agent over MCP or the CLI, and tests — no IMAP/SMTP knowledge. + +/** A parsed mail address, e.g. { name: "Ada", address: "ada@mail.profullstack.com" }. */ +export interface MailAddress { + name?: string; + address: string; +} + +/** An IMAP-style mailbox (folder) with unread/total counts. */ +export interface Mailbox { + /** Display name, e.g. "INBOX". */ + name: string; + /** Full path used by the transport, e.g. "INBOX" or "Archive/2026". */ + path: string; + unseen: number; + total: number; +} + +/** A lightweight message row for list/search views. */ +export interface MessageSummary { + uid: number; + mailbox: string; + from: MailAddress; + to: MailAddress[]; + subject: string; + /** RFC3339/ISO date string. */ + date: string; + seen: boolean; + flagged: boolean; + hasAttachments: boolean; + /** A short plain-text preview of the body. */ + snippet: string; +} + +/** Metadata for one attachment (bytes are fetched separately, not held here). */ +export interface Attachment { + filename: string; + contentType: string; + size: number; +} + +/** A fully fetched message. */ +export interface Message extends MessageSummary { + cc: MailAddress[]; + replyTo?: MailAddress; + messageId: string; + references: string[]; + text: string; + html?: string; + attachments: Attachment[]; +} + +/** An outgoing message to compose and send. */ +export interface Draft { + to: MailAddress[]; + cc?: MailAddress[]; + bcc?: MailAddress[]; + subject: string; + text: string; + html?: string; + /** Message-ID being replied to, for threading. */ + inReplyTo?: string; +} + +/** Loose RFC5322-ish address check — one @, a dotted domain, no spaces. */ +export function isValidEmail(value: string): boolean { + const v = value.trim(); + if (v.length < 3 || v.length > 254 || /\s/.test(v)) return false; + const at = v.lastIndexOf("@"); + if (at <= 0 || at === v.length - 1) return false; + return v.slice(at + 1).includes("."); +} + +/** The member's own address on the mail domain, e.g. alice@mail.profullstack.com. */ +export function mailboxAddress(localPart: string, domain: string): string { + return `${localPart}@${domain}`; +} + +/** Parse "Name " or a bare "addr@host" into a MailAddress. */ +export function parseAddress(raw: string): MailAddress { + const s = raw.trim(); + const m = /^(.*)<([^>]+)>\s*$/.exec(s); + if (m) { + const name = m[1].trim().replace(/^"|"$/g, "").trim(); + const address = m[2].trim(); + return name ? { name, address } : { address }; + } + return { address: s.replace(/^<|>$/g, "") }; +} + +/** Render a MailAddress back to "Name " (or just the address). */ +export function formatAddress(addr: MailAddress): string { + if (!addr.name) return addr.address; + const needsQuotes = /[",<>@]/.test(addr.name); + const name = needsQuotes ? `"${addr.name.replace(/"/g, '\\"')}"` : addr.name; + return `${name} <${addr.address}>`; +} + +/** Collapse a body to a single-line preview of at most `max` characters. */ +export function snippet(body: string, max = 140): string { + const flat = body.replace(/\s+/g, " ").trim(); + return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`; +} + +/** + * normalizeDraft validates and tidies a draft before it is handed to a + * transport: it trims the subject, drops empty recipient lists, and rejects + * a draft with no valid recipient. Throws DraftError on invalid input. + */ +export function normalizeDraft(draft: Draft): Draft { + const clean = (list?: MailAddress[]) => + (list ?? []).map((a) => ({ ...a, address: a.address.trim() })).filter((a) => a.address.length > 0); + + const to = clean(draft.to); + const cc = clean(draft.cc); + const bcc = clean(draft.bcc); + + const recipients = [...to, ...cc, ...bcc]; + if (recipients.length === 0) throw new DraftError("a draft needs at least one recipient"); + const bad = recipients.find((a) => !isValidEmail(a.address)); + if (bad) throw new DraftError(`invalid recipient address: ${bad.address}`); + + const normalized: Draft = { + to, + subject: (draft.subject ?? "").trim(), + text: draft.text ?? "" + }; + if (cc.length) normalized.cc = cc; + if (bcc.length) normalized.bcc = bcc; + if (draft.html !== undefined) normalized.html = draft.html; + if (draft.inReplyTo !== undefined) normalized.inReplyTo = draft.inReplyTo; + return normalized; +} + +/** Thrown when a draft is malformed (no/invalid recipients, etc.). */ +export class DraftError extends Error { + constructor(message: string) { + super(message); + this.name = "DraftError"; + } +} diff --git a/plugins/agentmail/src/index.ts b/plugins/agentmail/src/index.ts new file mode 100644 index 0000000..6feb87b --- /dev/null +++ b/plugins/agentmail/src/index.ts @@ -0,0 +1,40 @@ +import type { PluginDefinition } from "@logicsrc/plugin-core"; +import { agentMailManifest } from "./manifest.js"; + +export const agentMailPlugin: PluginDefinition = { + manifest: agentMailManifest, + configDefaults: { + enabled: false, + members_only: true, + paid_only: true, + domain: "${AGENTMAIL_DOMAIN}", + imap_host: "${AGENTMAIL_IMAP_HOST}", + imap_port: "${AGENTMAIL_IMAP_PORT}", + smtp_host: "${AGENTMAIL_SMTP_HOST}", + smtp_port: "${AGENTMAIL_SMTP_PORT}", + backend_provider: "mailu" + }, + routes: [ + { method: "GET", path: "/api/plugins/agentmail/mailboxes", capability: "mailbox.list" }, + { method: "GET", path: "/api/plugins/agentmail/mailboxes/:mailbox/messages", capability: "message.list" }, + { method: "GET", path: "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", capability: "message.read" }, + { method: "GET", path: "/api/plugins/agentmail/search", capability: "message.search" }, + { method: "POST", path: "/api/plugins/agentmail/messages", capability: "message.send" }, + { method: "PATCH", path: "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", capability: "message.flag" }, + { method: "DELETE", path: "/api/plugins/agentmail/mailboxes/:mailbox/messages/:uid", capability: "message.delete" } + ], + permissions: ["mail:read", "mail:search", "mail:compose", "mail:send", "mail:flag", "mail:delete"], + tuiPanels: [{ id: "agentmail-inbox", title: "Mail" }] +}; + +export { agentMailManifest }; + +// Domain, access, ports, service, and transports. +export * from "./domain.js"; +export * from "./access.js"; +export * from "./ports.js"; +export * from "./service.js"; +export { InMemoryMailTransport } from "./transports/memory.js"; +export type { SeedMessage } from "./transports/memory.js"; +export { createMailuTransport, resolveMailuConfig } from "./transports/mailu.js"; +export type { MailuConfig, ImapDriver, SmtpDriver, CreateMailuTransportOptions } from "./transports/mailu.js"; diff --git a/plugins/agentmail/src/manifest.ts b/plugins/agentmail/src/manifest.ts new file mode 100644 index 0000000..d8f7576 --- /dev/null +++ b/plugins/agentmail/src/manifest.ts @@ -0,0 +1,28 @@ +import type { PluginManifest } from "@logicsrc/plugin-core"; + +export const agentMailManifest: PluginManifest = { + id: "agentmail", + name: "AgentMail", + version: "0.1.0", + type: ["communication", "email", "mailbox"], + default: false, + capabilities: [ + "mailbox.list", + "message.list", + "message.read", + "message.search", + "message.compose", + "message.send", + "message.flag", + "message.delete", + "access.gate" + ], + commands: ["mail", "inbox", "compose"], + env: [ + "AGENTMAIL_DOMAIN", + "AGENTMAIL_IMAP_HOST", + "AGENTMAIL_IMAP_PORT", + "AGENTMAIL_SMTP_HOST", + "AGENTMAIL_SMTP_PORT" + ] +}; diff --git a/plugins/agentmail/src/ports.ts b/plugins/agentmail/src/ports.ts new file mode 100644 index 0000000..a05aac2 --- /dev/null +++ b/plugins/agentmail/src/ports.ts @@ -0,0 +1,50 @@ +// The transport seam. AgentMailService talks only to this interface, so the +// same service drives an in-memory fake (tests/dev), a real IMAP/SMTP backend +// (Mailu, see transports/mailu.ts), or any other provider. This mirrors how +// the agentgit plugin injects its forge adapter. + +import type { Draft, Mailbox, Message, MessageSummary } from "./domain.js"; + +export interface ListMessagesInput { + mailbox: string; + /** Max rows to return (newest first). */ + limit?: number; +} + +export interface SearchInput { + /** Mailbox to search; omitted means all mailboxes. */ + mailbox?: string; + /** Free-text query matched against subject, from, and body. */ + query: string; + limit?: number; +} + +export interface FlagChange { + seen?: boolean; + flagged?: boolean; +} + +export interface SendResult { + messageId: string; +} + +/** The low-level mailbox operations a backend must provide. */ +export interface MailTransport { + listMailboxes(): Promise; + listMessages(input: ListMessagesInput): Promise; + /** Returns null when the uid is unknown in that mailbox. */ + readMessage(mailbox: string, uid: number): Promise; + search(input: SearchInput): Promise; + /** Sends an already-normalized draft; the From header is stamped by the service. */ + send(from: string, draft: Draft): Promise; + setFlags(mailbox: string, uid: number, flags: FlagChange): Promise; + deleteMessage(mailbox: string, uid: number): Promise; +} + +/** Raised by transports for connection/protocol failures. */ +export class MailTransportError extends Error { + constructor(message: string) { + super(message); + this.name = "MailTransportError"; + } +} diff --git a/plugins/agentmail/src/service.test.ts b/plugins/agentmail/src/service.test.ts new file mode 100644 index 0000000..d1a1a88 --- /dev/null +++ b/plugins/agentmail/src/service.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { MailAccessError, type MailIdentity } from "./access.js"; +import { AgentMailService } from "./service.js"; +import { InMemoryMailTransport, type SeedMessage } from "./transports/memory.js"; + +const paid: MailIdentity = { name: "alice", paid: true }; +const free: MailIdentity = { name: "bob", paid: false }; +const domain = "mail.profullstack.com"; + +function seeded() { + const seed: SeedMessage[] = [ + { mailbox: "INBOX", from: { name: "Carol", address: "carol@example.com" }, subject: "Welcome", text: "hi alice", date: "2026-06-01T10:00:00.000Z", uid: 1 }, + { mailbox: "INBOX", from: { address: "deploy@ci.example.com" }, subject: "Build passed", text: "all green", date: "2026-06-02T10:00:00.000Z", uid: 2 } + ]; + return new InMemoryMailTransport(seed); +} + +function service(identity: MailIdentity, transport = seeded()) { + return { svc: new AgentMailService({ transport, identity, domain }), transport }; +} + +describe("access gating", () => { + it("blocks every call for a non-paid identity", async () => { + const { svc } = service(free); + await expect(svc.inbox()).rejects.toThrow(MailAccessError); + await expect(svc.mailboxes()).rejects.toThrow(MailAccessError); + await expect(svc.send({ to: [{ address: "x@y.com" }], subject: "s", text: "t" })).rejects.toThrow(MailAccessError); + }); +}); + +describe("address", () => { + it("is the member's local part on the mail domain", () => { + const { svc } = service(paid); + expect(svc.address()).toBe("alice@mail.profullstack.com"); + }); +}); + +describe("inbox / list", () => { + it("returns newest-first summaries", async () => { + const { svc } = service(paid); + const inbox = await svc.inbox(); + expect(inbox.map((m) => m.uid)).toEqual([2, 1]); + expect(inbox[0].subject).toBe("Build passed"); + }); +}); + +describe("read", () => { + it("marks a message seen by default and peek leaves it unseen", async () => { + const { svc, transport } = service(paid); + const m = await svc.read("INBOX", 1); + expect(m?.seen).toBe(true); + expect((await transport.readMessage("INBOX", 1))?.seen).toBe(true); + + transport.add({ mailbox: "INBOX", from: { address: "z@z.com" }, subject: "Peek", text: "x", uid: 9 }); + await svc.read("INBOX", 9, { peek: true }); + expect((await transport.readMessage("INBOX", 9))?.seen).toBe(false); + }); + + it("returns null for an unknown uid", async () => { + const { svc } = service(paid); + expect(await svc.read("INBOX", 999)).toBeNull(); + }); +}); + +describe("search", () => { + it("matches subject, sender, and body across mailboxes", async () => { + const { svc } = service(paid); + expect((await svc.search("green")).map((m) => m.uid)).toEqual([2]); + expect((await svc.search("carol@example.com")).map((m) => m.uid)).toEqual([1]); + }); +}); + +describe("send", () => { + it("stamps From, normalizes, and lands in Sent", async () => { + const { svc, transport } = service(paid); + const res = await svc.send({ to: [{ address: "carol@example.com" }], subject: " Hello ", text: "hi" }); + expect(res.messageId).toMatch(/@mail\.profullstack\.com>$/); + const sent = await transport.listMessages({ mailbox: "Sent" }); + expect(sent).toHaveLength(1); + expect(sent[0].from.address).toBe("alice@mail.profullstack.com"); + expect(sent[0].subject).toBe("Hello"); + }); + + it("rejects a draft with no recipients", async () => { + const { svc } = service(paid); + await expect(svc.send({ to: [], subject: "x", text: "y" })).rejects.toThrow(/recipient/); + }); +}); + +describe("reply", () => { + it("addresses the sender, prefixes Re:, and threads via In-Reply-To", async () => { + const { svc, transport } = service(paid); + const original = await svc.read("INBOX", 1, { peek: true }); + await svc.reply(original!, "thanks"); + const sent = await transport.listMessages({ mailbox: "Sent" }); + expect(sent[0].to[0].address).toBe("carol@example.com"); + expect(sent[0].subject).toBe("Re: Welcome"); + const full = await transport.readMessage("Sent", sent[0].uid); + expect(full?.references).toContain(original!.messageId); + }); +}); + +describe("flags and delete", () => { + it("flags, unflags, and deletes", async () => { + const { svc, transport } = service(paid); + await svc.flag("INBOX", 1); + expect((await transport.readMessage("INBOX", 1))?.flagged).toBe(true); + await svc.delete("INBOX", 1); + expect(await transport.readMessage("INBOX", 1)).toBeNull(); + }); +}); diff --git a/plugins/agentmail/src/service.ts b/plugins/agentmail/src/service.ts new file mode 100644 index 0000000..4934460 --- /dev/null +++ b/plugins/agentmail/src/service.ts @@ -0,0 +1,116 @@ +// AgentMailService is the single entry point a TUI, CLI, or agent uses. It is +// transport-agnostic (talks to a MailTransport), paid-gated (every call asserts +// a Founding Lifetime identity), and returns plain JSON-serializable domain +// objects so the same API is ergonomic for humans and bots alike. + +import { assertPaid, type MailIdentity } from "./access.js"; +import { type Draft, type Mailbox, type Message, type MessageSummary, mailboxAddress, normalizeDraft } from "./domain.js"; +import type { FlagChange, MailTransport, SearchInput, SendResult } from "./ports.js"; + +export interface AgentMailServiceOptions { + transport: MailTransport; + identity: MailIdentity; + /** Mail domain for the member's own address, e.g. "mail.profullstack.com". */ + domain: string; + /** Default page size for inbox/list calls. */ + defaultLimit?: number; +} + +const DEFAULT_LIMIT = 50; +const INBOX = "INBOX"; + +export class AgentMailService { + private readonly transport: MailTransport; + private readonly identity: MailIdentity; + private readonly domain: string; + private readonly defaultLimit: number; + + constructor(opts: AgentMailServiceOptions) { + this.transport = opts.transport; + this.identity = opts.identity; + this.domain = opts.domain; + this.defaultLimit = opts.defaultLimit ?? DEFAULT_LIMIT; + } + + /** The member's own address, e.g. alice@mail.profullstack.com. */ + address(): string { + return mailboxAddress(this.identity.name, this.domain); + } + + /** List all mailboxes (folders) with unread/total counts. */ + async mailboxes(): Promise { + assertPaid(this.identity); + return this.transport.listMailboxes(); + } + + /** Newest-first summaries for a mailbox (INBOX by default). */ + async list(mailbox = INBOX, limit?: number): Promise { + assertPaid(this.identity); + return this.transport.listMessages({ mailbox, limit: limit ?? this.defaultLimit }); + } + + /** Convenience for the INBOX. */ + async inbox(limit?: number): Promise { + return this.list(INBOX, limit); + } + + /** Fetch one full message; marks it seen unless { peek: true }. */ + async read(mailbox: string, uid: number, opts: { peek?: boolean } = {}): Promise { + assertPaid(this.identity); + const message = await this.transport.readMessage(mailbox, uid); + if (message && !opts.peek && !message.seen) { + await this.transport.setFlags(mailbox, uid, { seen: true }); + message.seen = true; + } + return message; + } + + /** Free-text search across a mailbox (or all if omitted). */ + async search(query: string, opts: Omit = {}): Promise { + assertPaid(this.identity); + return this.transport.search({ query, limit: this.defaultLimit, ...opts }); + } + + /** Validate, stamp From, and send a draft. Returns the new Message-ID. */ + async send(draft: Draft): Promise { + assertPaid(this.identity); + const normalized = normalizeDraft(draft); + return this.transport.send(this.address(), normalized); + } + + /** + * Reply builds a draft addressed back to the original sender (and, when + * replyAll, the other recipients minus the member) with a "Re:" subject and + * In-Reply-To set for threading, then sends it. + */ + async reply(original: Message, text: string, opts: { replyAll?: boolean; html?: string } = {}): Promise { + assertPaid(this.identity); + const self = this.address().toLowerCase(); + const to = [original.replyTo ?? original.from]; + const cc = opts.replyAll + ? [...original.to, ...original.cc].filter((a) => a.address.toLowerCase() !== self && a.address.toLowerCase() !== to[0].address.toLowerCase()) + : undefined; + const subject = /^re:/i.test(original.subject) ? original.subject : `Re: ${original.subject}`; + return this.send({ to, cc, subject, text, html: opts.html, inReplyTo: original.messageId }); + } + + async markSeen(mailbox: string, uid: number, seen = true): Promise { + assertPaid(this.identity); + return this.transport.setFlags(mailbox, uid, { seen }); + } + + async flag(mailbox: string, uid: number, flagged = true): Promise { + assertPaid(this.identity); + return this.transport.setFlags(mailbox, uid, { flagged }); + } + + async setFlags(mailbox: string, uid: number, flags: FlagChange): Promise { + assertPaid(this.identity); + return this.transport.setFlags(mailbox, uid, flags); + } + + async delete(mailbox: string, uid: number): Promise { + assertPaid(this.identity); + return this.transport.deleteMessage(mailbox, uid); + } +} diff --git a/plugins/agentmail/src/transports/mailu.ts b/plugins/agentmail/src/transports/mailu.ts new file mode 100644 index 0000000..46b3d90 --- /dev/null +++ b/plugins/agentmail/src/transports/mailu.ts @@ -0,0 +1,89 @@ +// Mailu transport: the seam to the self-hosted mail stack (Postfix + Dovecot) +// at mail.profullstack.com. To keep heavy network libs out of the plugin (the +// repo convention — cf. agentgit injecting its forge adapter), the actual +// IMAP/SMTP drivers are injected. A consuming app wires concrete drivers +// (e.g. imapflow + nodemailer in Node, or the Go client on the BBS); this +// module owns config resolution and presents them as one MailTransport. + +import type { Draft, Mailbox, Message, MessageSummary } from "../domain.js"; +import { MailTransportError, type FlagChange, type ListMessagesInput, type MailTransport, type SearchInput, type SendResult } from "../ports.js"; + +export interface MailuConfig { + /** Mail domain for member addresses, e.g. "mail.profullstack.com". */ + domain: string; + imap: { host: string; port: number; secure: boolean }; + smtp: { host: string; port: number; secure: boolean }; + /** Per-member IMAP/SMTP credentials (Dovecot/Postfix auth). */ + auth: { user: string; pass: string }; +} + +/** Low-level IMAP operations a driver must implement (returns domain types). */ +export interface ImapDriver { + listMailboxes(): Promise; + listMessages(input: ListMessagesInput): Promise; + readMessage(mailbox: string, uid: number): Promise; + search(input: SearchInput): Promise; + setFlags(mailbox: string, uid: number, flags: FlagChange): Promise; + deleteMessage(mailbox: string, uid: number): Promise; +} + +/** Low-level SMTP submission a driver must implement. */ +export interface SmtpDriver { + send(from: string, draft: Draft): Promise; +} + +export interface CreateMailuTransportOptions { + config: MailuConfig; + imap: ImapDriver; + smtp: SmtpDriver; +} + +/** + * resolveMailuConfig builds a MailuConfig from the environment, defaulting to + * the production hosts (mail.profullstack.com over IMAPS:993, smtp.profullstack.com + * over submission:587/STARTTLS). The caller supplies the member's credentials. + */ +export function resolveMailuConfig(auth: { user: string; pass: string }, env: Record = process.env): MailuConfig { + const domain = env.AGENTMAIL_DOMAIN ?? "mail.profullstack.com"; + return { + domain, + imap: { + host: env.AGENTMAIL_IMAP_HOST ?? domain, + port: Number(env.AGENTMAIL_IMAP_PORT ?? 993), + secure: (env.AGENTMAIL_IMAP_SECURE ?? "true") !== "false" + }, + smtp: { + host: env.AGENTMAIL_SMTP_HOST ?? "smtp.profullstack.com", + port: Number(env.AGENTMAIL_SMTP_PORT ?? 587), + secure: (env.AGENTMAIL_SMTP_SECURE ?? "false") === "true" + }, + auth + }; +} + +/** + * createMailuTransport wires injected IMAP/SMTP drivers into a MailTransport. + * Drivers must be supplied; otherwise every call fails fast with a clear error + * instead of silently doing nothing. + */ +export function createMailuTransport(opts: CreateMailuTransportOptions): MailTransport { + const { config, imap, smtp } = opts; + if (!imap || !smtp) { + throw new MailTransportError("createMailuTransport requires both imap and smtp drivers"); + } + return { + listMailboxes: () => imap.listMailboxes(), + listMessages: (input) => imap.listMessages(input), + readMessage: (mailbox, uid) => imap.readMessage(mailbox, uid), + search: (input) => imap.search(input), + setFlags: (mailbox, uid, flags) => imap.setFlags(mailbox, uid, flags), + deleteMessage: (mailbox, uid) => imap.deleteMessage(mailbox, uid), + send: (from, draft) => { + const expected = `@${config.domain}`; + if (!from.endsWith(expected)) { + throw new MailTransportError(`sender ${from} is not on the mail domain ${config.domain}`); + } + return smtp.send(from, draft); + } + }; +} diff --git a/plugins/agentmail/src/transports/memory.ts b/plugins/agentmail/src/transports/memory.ts new file mode 100644 index 0000000..f937d0c --- /dev/null +++ b/plugins/agentmail/src/transports/memory.ts @@ -0,0 +1,139 @@ +// InMemoryMailTransport is a complete, dependency-free MailTransport used by +// tests, local development, and as the reference for what a real backend must +// do. Sending appends to "Sent"; UIDs are assigned per process. + +import { type Draft, type Mailbox, type Message, type MessageSummary, snippet } from "../domain.js"; +import type { FlagChange, ListMessagesInput, MailTransport, SearchInput, SendResult } from "../ports.js"; + +function toSummary(m: Message): MessageSummary { + return { + uid: m.uid, + mailbox: m.mailbox, + from: m.from, + to: m.to, + subject: m.subject, + date: m.date, + seen: m.seen, + flagged: m.flagged, + hasAttachments: m.attachments.length > 0, + snippet: m.snippet + }; +} + +export interface SeedMessage extends Partial { + mailbox: string; + from: Message["from"]; + subject: string; + text: string; +} + +export class InMemoryMailTransport implements MailTransport { + private readonly byMailbox = new Map(); + private nextUid = 1; + + constructor(seed: SeedMessage[] = []) { + for (const s of seed) this.add(s); + } + + /** Insert a message, filling in defaults; returns the stored copy. */ + add(seed: SeedMessage): Message { + const text = seed.text ?? ""; + const message: Message = { + uid: seed.uid ?? this.nextUid++, + mailbox: seed.mailbox, + from: seed.from, + to: seed.to ?? [], + cc: seed.cc ?? [], + replyTo: seed.replyTo, + subject: seed.subject, + date: seed.date ?? new Date().toISOString(), + seen: seed.seen ?? false, + flagged: seed.flagged ?? false, + hasAttachments: (seed.attachments ?? []).length > 0, + snippet: seed.snippet ?? snippet(text), + messageId: seed.messageId ?? `<${cryptoRandom()}@memory.local>`, + references: seed.references ?? [], + text, + html: seed.html, + attachments: seed.attachments ?? [] + }; + if (message.uid >= this.nextUid) this.nextUid = message.uid + 1; + const list = this.byMailbox.get(message.mailbox) ?? []; + list.push(message); + this.byMailbox.set(message.mailbox, list); + return message; + } + + async listMailboxes(): Promise { + return [...this.byMailbox.entries()].map(([path, list]) => ({ + name: path, + path, + total: list.length, + unseen: list.filter((m) => !m.seen).length + })); + } + + async listMessages(input: ListMessagesInput): Promise { + const list = [...(this.byMailbox.get(input.mailbox) ?? [])]; + list.sort((a, b) => b.date.localeCompare(a.date)); + const limited = input.limit ? list.slice(0, input.limit) : list; + return limited.map(toSummary); + } + + async readMessage(mailbox: string, uid: number): Promise { + const found = (this.byMailbox.get(mailbox) ?? []).find((m) => m.uid === uid); + return found ? { ...found } : null; + } + + async search(input: SearchInput): Promise { + const q = input.query.toLowerCase(); + const mailboxes = input.mailbox ? [input.mailbox] : [...this.byMailbox.keys()]; + const hits: Message[] = []; + for (const mb of mailboxes) { + for (const m of this.byMailbox.get(mb) ?? []) { + const haystack = `${m.subject} ${m.from.address} ${m.from.name ?? ""} ${m.text}`.toLowerCase(); + if (haystack.includes(q)) hits.push(m); + } + } + hits.sort((a, b) => b.date.localeCompare(a.date)); + const limited = input.limit ? hits.slice(0, input.limit) : hits; + return limited.map(toSummary); + } + + async send(from: string, draft: Draft): Promise { + const messageId = `<${cryptoRandom()}@${from.split("@")[1] ?? "memory.local"}>`; + this.add({ + mailbox: "Sent", + from: { address: from }, + to: draft.to, + cc: draft.cc, + subject: draft.subject, + text: draft.text, + html: draft.html, + seen: true, + messageId, + references: draft.inReplyTo ? [draft.inReplyTo] : [] + }); + return { messageId }; + } + + async setFlags(mailbox: string, uid: number, flags: FlagChange): Promise { + const m = (this.byMailbox.get(mailbox) ?? []).find((x) => x.uid === uid); + if (!m) return; + if (flags.seen !== undefined) m.seen = flags.seen; + if (flags.flagged !== undefined) m.flagged = flags.flagged; + } + + async deleteMessage(mailbox: string, uid: number): Promise { + const list = this.byMailbox.get(mailbox); + if (!list) return; + this.byMailbox.set( + mailbox, + list.filter((m) => m.uid !== uid) + ); + } +} + +function cryptoRandom(): string { + return Math.random().toString(36).slice(2, 12); +} diff --git a/plugins/agentmail/tsconfig.json b/plugins/agentmail/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/plugins/agentmail/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}