mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 20:57:03 +00:00
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:
parent
bf046ae280
commit
e304eac761
14 changed files with 908 additions and 1 deletions
89
plugins/agentmail/src/transports/mailu.ts
Normal file
89
plugins/agentmail/src/transports/mailu.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
139
plugins/agentmail/src/transports/memory.ts
Normal file
139
plugins/agentmail/src/transports/memory.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue