Add AgentMail plugin: paid-member mailbox client for humans + agents

@logicsrc/plugin-agentmail provides read/search/compose/send/flag/delete
over an injected MailTransport, gated to Founding Lifetime (paid) members.
Returns plain JSON-serializable domain objects so the same API serves a
human TUI, the CLI, MCP, and bots.

- domain.ts: transport-agnostic types + pure helpers (parse/format address,
  normalizeDraft, isValidEmail, snippet)
- ports.ts: MailTransport seam
- service.ts: AgentMailService (inbox/list/read/search/send/reply/flag/delete)
- access.ts: paid-member gate (assertPaid) + capability constants
- transports/memory.ts: complete in-memory backend (tests/dev/reference)
- transports/mailu.ts: self-hosted Mailu seam (mail.profullstack.com IMAP +
  smtp.profullstack.com submission) with injected IMAP/SMTP drivers
- index.ts: PluginDefinition (manifest, routes, capabilities, tuiPanels)
- registered in the root build; 20 vitest tests pass

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 15:37:17 +00:00
parent bf046ae280
commit e304eac761
14 changed files with 908 additions and 1 deletions

View file

@ -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<Mailbox[]>;
listMessages(input: ListMessagesInput): Promise<MessageSummary[]>;
readMessage(mailbox: string, uid: number): Promise<Message | null>;
search(input: SearchInput): Promise<MessageSummary[]>;
setFlags(mailbox: string, uid: number, flags: FlagChange): Promise<void>;
deleteMessage(mailbox: string, uid: number): Promise<void>;
}
/** Low-level SMTP submission a driver must implement. */
export interface SmtpDriver {
send(from: string, draft: Draft): Promise<SendResult>;
}
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<string, string | undefined> = 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);
}
};
}

View file

@ -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<Message> {
mailbox: string;
from: Message["from"];
subject: string;
text: string;
}
export class InMemoryMailTransport implements MailTransport {
private readonly byMailbox = new Map<string, Message[]>();
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<Mailbox[]> {
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<MessageSummary[]> {
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<Message | null> {
const found = (this.byMailbox.get(mailbox) ?? []).find((m) => m.uid === uid);
return found ? { ...found } : null;
}
async search(input: SearchInput): Promise<MessageSummary[]> {
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<SendResult> {
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<void> {
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<void> {
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);
}