mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +00:00
@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>
116 lines
4.5 KiB
TypeScript
116 lines
4.5 KiB
TypeScript
// 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);
|
|
}
|
|
}
|