Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions plugins/agentmail/README.md
Original file line number Diff line number Diff line change
@@ -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) |
18 changes: 18 additions & 0 deletions plugins/agentmail/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
40 changes: 40 additions & 0 deletions plugins/agentmail/src/access.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
63 changes: 63 additions & 0 deletions plugins/agentmail/src/domain.test.ts
Original file line number Diff line number Diff line change
@@ -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 <ada@mail.profullstack.com>")).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 <ada@x.com>");
expect(formatAddress({ address: "ada@x.com" })).toBe("ada@x.com");
expect(formatAddress({ name: "Doe, John", address: "j@x.com" })).toBe('"Doe, John" <j@x.com>');
});
});

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/);
});
});
143 changes: 143 additions & 0 deletions plugins/agentmail/src/domain.ts
Original file line number Diff line number Diff line change
@@ -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 <addr@host>" 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 <addr>" (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";
}
}
40 changes: 40 additions & 0 deletions plugins/agentmail/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading