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,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();
}
}

View file

@ -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/);
});
});

View file

@ -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";
}
}

View file

@ -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";

View file

@ -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"
]
};

View file

@ -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<Mailbox[]>;
listMessages(input: ListMessagesInput): Promise<MessageSummary[]>;
/** Returns null when the uid is unknown in that mailbox. */
readMessage(mailbox: string, uid: number): Promise<Message | null>;
search(input: SearchInput): Promise<MessageSummary[]>;
/** Sends an already-normalized draft; the From header is stamped by the service. */
send(from: string, draft: Draft): Promise<SendResult>;
setFlags(mailbox: string, uid: number, flags: FlagChange): Promise<void>;
deleteMessage(mailbox: string, uid: number): Promise<void>;
}
/** Raised by transports for connection/protocol failures. */
export class MailTransportError extends Error {
constructor(message: string) {
super(message);
this.name = "MailTransportError";
}
}

View file

@ -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();
});
});

View file

@ -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<Mailbox[]> {
assertPaid(this.identity);
return this.transport.listMailboxes();
}
/** Newest-first summaries for a mailbox (INBOX by default). */
async list(mailbox = INBOX, limit?: number): Promise<MessageSummary[]> {
assertPaid(this.identity);
return this.transport.listMessages({ mailbox, limit: limit ?? this.defaultLimit });
}
/** Convenience for the INBOX. */
async inbox(limit?: number): Promise<MessageSummary[]> {
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<Message | null> {
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<SearchInput, "query"> = {}): Promise<MessageSummary[]> {
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<SendResult> {
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<SendResult> {
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<void> {
assertPaid(this.identity);
return this.transport.setFlags(mailbox, uid, { seen });
}
async flag(mailbox: string, uid: number, flagged = true): Promise<void> {
assertPaid(this.identity);
return this.transport.setFlags(mailbox, uid, { flagged });
}
async setFlags(mailbox: string, uid: number, flags: FlagChange): Promise<void> {
assertPaid(this.identity);
return this.transport.setFlags(mailbox, uid, flags);
}
async delete(mailbox: string, uid: number): Promise<void> {
assertPaid(this.identity);
return this.transport.deleteMessage(mailbox, uid);
}
}

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);
}