mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +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
111
plugins/agentmail/src/service.test.ts
Normal file
111
plugins/agentmail/src/service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue