mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 21:27:07 +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
116
plugins/agentmail/src/service.ts
Normal file
116
plugins/agentmail/src/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue