mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +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
|
|
@ -12,7 +12,7 @@
|
|||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
|
||||
"build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build",
|
||||
"start": "npm --workspace @logicsrc/web run start",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"check": "npm run build && npm run test",
|
||||
|
|
|
|||
62
plugins/agentmail/README.md
Normal file
62
plugins/agentmail/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# @logicsrc/plugin-agentmail
|
||||
|
||||
Agent-native mail for LogicSRC: a paid-member mailbox client (read, search,
|
||||
compose, send, flag, delete) that works the same for **humans** (a TUI) and
|
||||
**bots/agents** (the CLI, MCP, or direct service calls), because every method
|
||||
returns plain JSON-serializable domain objects.
|
||||
|
||||
## Design
|
||||
|
||||
The plugin follows the LogicSRC plugin pattern (cf. `agentgit`):
|
||||
|
||||
- **`domain.ts`** — transport-agnostic types (`Mailbox`, `MessageSummary`,
|
||||
`Message`, `Draft`, `MailAddress`) and pure helpers (`parseAddress`,
|
||||
`formatAddress`, `normalizeDraft`, `isValidEmail`, `snippet`).
|
||||
- **`ports.ts`** — the `MailTransport` seam. The service talks only to this.
|
||||
- **`service.ts`** — `AgentMailService`: paid-gated, ergonomic operations
|
||||
(`inbox`, `list`, `read`, `search`, `send`, `reply`, `flag`, `delete`).
|
||||
- **`access.ts`** — mail is a **Founding Lifetime Member** (paid) perk; every
|
||||
call runs `assertPaid`.
|
||||
- **`transports/memory.ts`** — `InMemoryMailTransport`: a complete, dependency
|
||||
free backend for tests, local dev, and as the reference implementation.
|
||||
- **`transports/mailu.ts`** — the self-hosted Mailu seam
|
||||
(`mail.profullstack.com` IMAP + `smtp.profullstack.com` submission). It takes
|
||||
**injected** IMAP/SMTP drivers so heavy network libraries stay out of the
|
||||
plugin; a consuming app supplies the concrete drivers (e.g. `imapflow` +
|
||||
`nodemailer` in Node, or the Go client on the BBS).
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { AgentMailService, InMemoryMailTransport } from "@logicsrc/plugin-agentmail";
|
||||
|
||||
const transport = new InMemoryMailTransport();
|
||||
const mail = new AgentMailService({
|
||||
transport,
|
||||
identity: { name: "alice", paid: true },
|
||||
domain: "mail.profullstack.com"
|
||||
});
|
||||
|
||||
await mail.send({ to: [{ address: "carol@example.com" }], subject: "Hi", text: "hello" });
|
||||
const inbox = await mail.inbox();
|
||||
```
|
||||
|
||||
Against the real stack:
|
||||
|
||||
```ts
|
||||
import { AgentMailService, createMailuTransport, resolveMailuConfig } from "@logicsrc/plugin-agentmail";
|
||||
|
||||
const config = resolveMailuConfig({ user: "alice", pass: process.env.MAIL_PASS! });
|
||||
const transport = createMailuTransport({ config, imap, smtp }); // imap/smtp: app-provided drivers
|
||||
const mail = new AgentMailService({ transport, identity: { name: "alice", paid: true }, domain: config.domain });
|
||||
```
|
||||
|
||||
## Config / env
|
||||
|
||||
| Var | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `AGENTMAIL_DOMAIN` | `mail.profullstack.com` | member address domain |
|
||||
| `AGENTMAIL_IMAP_HOST` | `mail.profullstack.com` | IMAP host |
|
||||
| `AGENTMAIL_IMAP_PORT` | `993` | IMAPS port |
|
||||
| `AGENTMAIL_SMTP_HOST` | `smtp.profullstack.com` | SMTP submission host |
|
||||
| `AGENTMAIL_SMTP_PORT` | `587` | submission port (STARTTLS) |
|
||||
18
plugins/agentmail/package.json
Normal file
18
plugins/agentmail/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@logicsrc/plugin-agentmail",
|
||||
"version": "0.1.0",
|
||||
"description": "Agent-native mail: a paid-member mailbox client (read/search/compose/send) over an injected IMAP/SMTP transport, designed for both bots and humans.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run src --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logicsrc/plugin-core": "file:../../packages/plugin-core"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
40
plugins/agentmail/src/access.ts
Normal file
40
plugins/agentmail/src/access.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
63
plugins/agentmail/src/domain.test.ts
Normal file
63
plugins/agentmail/src/domain.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
143
plugins/agentmail/src/domain.ts
Normal file
143
plugins/agentmail/src/domain.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
40
plugins/agentmail/src/index.ts
Normal file
40
plugins/agentmail/src/index.ts
Normal 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";
|
||||
28
plugins/agentmail/src/manifest.ts
Normal file
28
plugins/agentmail/src/manifest.ts
Normal 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"
|
||||
]
|
||||
};
|
||||
50
plugins/agentmail/src/ports.ts
Normal file
50
plugins/agentmail/src/ports.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
8
plugins/agentmail/tsconfig.json
Normal file
8
plugins/agentmail/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue