diff --git a/extensions/agentmail/src/accounts.test.ts b/extensions/agentmail/src/accounts.test.ts index 3bf6bb96a..6eee0928b 100644 --- a/extensions/agentmail/src/accounts.test.ts +++ b/extensions/agentmail/src/accounts.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - listAgentMailAccountIds, - resolveAgentMailAccount, - resolveCredentials, - resolveDefaultAgentMailAccountId, -} from "./accounts.js"; +import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js"; import type { CoreConfig } from "./utils.js"; describe("resolveCredentials", () => { @@ -99,7 +94,9 @@ describe("resolveCredentials", () => { AGENTMAIL_WEBHOOK_URL: "https://env-gateway.example.com/hooks/email", }; const result = resolveCredentials(cfg, env); - expect(result.webhookUrl).toBe("https://env-gateway.example.com/hooks/email"); + expect(result.webhookUrl).toBe( + "https://env-gateway.example.com/hooks/email" + ); }); it("derives webhookPath from webhookUrl", () => { @@ -218,19 +215,3 @@ describe("resolveAgentMailAccount", () => { expect(result.name).toBe("Trimmed Name"); }); }); - -describe("listAgentMailAccountIds", () => { - it("returns default account", () => { - const cfg: CoreConfig = {}; - const result = listAgentMailAccountIds(cfg); - expect(result).toEqual(["default"]); - }); -}); - -describe("resolveDefaultAgentMailAccountId", () => { - it("returns default account id", () => { - const cfg: CoreConfig = {}; - const result = resolveDefaultAgentMailAccountId(cfg); - expect(result).toBe("default"); - }); -}); diff --git a/extensions/agentmail/src/accounts.ts b/extensions/agentmail/src/accounts.ts index c80f257bf..c158f023d 100644 --- a/extensions/agentmail/src/accounts.ts +++ b/extensions/agentmail/src/accounts.ts @@ -6,22 +6,6 @@ import type { ResolvedAgentMailAccount, } from "./utils.js"; -/** - * Lists all AgentMail account IDs. - * Currently supports only a single default account. - */ -export function listAgentMailAccountIds(_cfg: CoreConfig): string[] { - return [DEFAULT_ACCOUNT_ID]; -} - -/** - * Returns the default AgentMail account ID. - * Currently only supports a single default account. - */ -export function resolveDefaultAgentMailAccountId(_cfg: CoreConfig): string { - return DEFAULT_ACCOUNT_ID; -} - /** Resolved AgentMail credentials and paths. */ export type ResolvedAgentMailCredentials = { apiKey?: string; @@ -30,10 +14,10 @@ export type ResolvedAgentMailCredentials = { webhookPath: string; }; -const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail"; +export const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail"; /** Extracts the path from a URL string. Returns undefined if just root "/". */ -function extractPathFromUrl(url: string): string | undefined { +export function extractPathFromUrl(url: string): string | undefined { try { const parsed = new URL(url); // Return undefined if just root path "/" - use default instead diff --git a/extensions/agentmail/src/channel.ts b/extensions/agentmail/src/channel.ts index cc86966f8..cf5bf6127 100644 --- a/extensions/agentmail/src/channel.ts +++ b/extensions/agentmail/src/channel.ts @@ -8,12 +8,7 @@ import { type ChannelPlugin, } from "clawdbot/plugin-sdk"; -import { - listAgentMailAccountIds, - resolveAgentMailAccount, - resolveDefaultAgentMailAccountId, - resolveCredentials, -} from "./accounts.js"; +import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js"; import { getAgentMailClient } from "./client.js"; import { AgentMailConfigSchema } from "./config-schema.js"; import { agentmailOnboardingAdapter } from "./onboarding.js"; @@ -47,11 +42,10 @@ export const agentmailPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(AgentMailConfigSchema), config: { - listAccountIds: (cfg) => listAgentMailAccountIds(cfg as CoreConfig), + listAccountIds: () => [DEFAULT_ACCOUNT_ID], resolveAccount: (cfg, accountId) => resolveAgentMailAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => - resolveDefaultAgentMailAccountId(cfg as CoreConfig), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ cfg: cfg as CoreConfig, @@ -102,18 +96,10 @@ export const agentmailPlugin: ChannelPlugin = { "Add email addresses or domains to channels.agentmail.allowFrom", normalizeEntry: (raw) => raw.toLowerCase().trim(), }), - collectWarnings: ({ account }) => { - const warnings: string[] = []; - const { allowFrom = [] } = account.config; - - if (allowFrom.length === 0) { - warnings.push( - "- AgentMail: No allowFrom configured. All senders will be allowed." - ); - } - - return warnings; - }, + collectWarnings: ({ account }) => + (account.config.allowFrom?.length ?? 0) === 0 + ? ["- AgentMail: No allowFrom configured. All senders will be allowed."] + : [], }, messaging: { @@ -147,25 +133,19 @@ export const agentmailPlugin: ChannelPlugin = { }, applyAccountConfig: ({ cfg, input }) => { const existing = (cfg as CoreConfig).channels?.agentmail ?? {}; + const updates: Record = { enabled: true }; + if (!input.useEnv) { + if (input.token?.trim()) updates.token = input.token.trim(); + if (input.emailAddress?.trim()) + updates.emailAddress = input.emailAddress.trim(); + if (input.webhookPath?.trim()) + updates.webhookPath = input.webhookPath.trim(); + } return { ...cfg, channels: { ...(cfg as CoreConfig).channels, - agentmail: { - ...existing, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.token?.trim() ? { token: input.token.trim() } : {}), - ...(input.emailAddress?.trim() - ? { emailAddress: input.emailAddress.trim() } - : {}), - ...(input.webhookPath?.trim() - ? { webhookPath: input.webhookPath.trim() } - : {}), - }), - }, + agentmail: { ...existing, ...updates }, }, }; }, @@ -182,54 +162,39 @@ export const agentmailPlugin: ChannelPlugin = { lastError: null, }, collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = - typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) return []; - return [ - { - channel: "agentmail", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - emailAddress: snapshot.emailAddress ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, + accounts + .filter((a) => typeof a.lastError === "string" && a.lastError.trim()) + .map((a) => ({ + channel: "agentmail", + accountId: a.accountId, + kind: "runtime" as const, + message: `Channel error: ${a.lastError}`, + })), + buildChannelSummary: ({ snapshot: s }) => ({ + configured: s.configured ?? false, + emailAddress: s.emailAddress ?? null, + running: s.running ?? false, + lastStartAt: s.lastStartAt ?? null, + lastStopAt: s.lastStopAt ?? null, + lastError: s.lastError ?? null, + probe: s.probe, + lastProbeAt: s.lastProbeAt ?? null, }), probeAccount: async ({ cfg }) => { try { const { apiKey, inboxId } = resolveCredentials(cfg as CoreConfig); - - if (!apiKey || !inboxId) { + if (!apiKey || !inboxId) return { ok: false, error: "Missing token or email address", elapsedMs: 0, }; - } - const start = Date.now(); - const client = getAgentMailClient(apiKey); - - // Probe by getting inbox info - const inbox = await client.inboxes.get(inboxId); - const elapsedMs = Date.now() - start; - + const inbox = await getAgentMailClient(apiKey).inboxes.get(inboxId); return { ok: true, - elapsedMs, - meta: { - inboxId: inbox.inboxId, // inboxId is the email address - }, + elapsedMs: Date.now() - start, + meta: { inboxId: inbox.inboxId }, }; } catch (err) { return { @@ -239,40 +204,35 @@ export const agentmailPlugin: ChannelPlugin = { }; } }, - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - emailAddress: account.inboxId, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, + buildAccountSnapshot: ({ account: a, runtime: r, probe }) => ({ + accountId: a.accountId, + name: a.name, + enabled: a.enabled, + configured: a.configured, + emailAddress: a.inboxId, + running: r?.running ?? false, + lastStartAt: r?.lastStartAt ?? null, + lastStopAt: r?.lastStopAt ?? null, + lastError: r?.lastError ?? null, probe, - lastProbeAt: runtime?.lastProbeAt ?? null, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, + lastProbeAt: r?.lastProbeAt ?? null, + lastInboundAt: r?.lastInboundAt ?? null, + lastOutboundAt: r?.lastOutboundAt ?? null, }), }, gateway: { startAccount: async (ctx) => { - const account = ctx.account; - ctx.setStatus({ - accountId: account.accountId, - emailAddress: account.inboxId, - }); + const { accountId, inboxId } = ctx.account; + ctx.setStatus({ accountId, emailAddress: inboxId }); ctx.log?.info( - `[${account.accountId}] starting AgentMail provider (email: ${ - account.inboxId ?? "unknown" + `[${accountId}] starting AgentMail provider (email: ${ + inboxId ?? "unknown" })` ); - - // Lazy import: avoid ESM init cycles const { monitorAgentMailProvider } = await import("./monitor.js"); return monitorAgentMailProvider({ - accountId: account.accountId, + accountId, abortSignal: ctx.abortSignal, }); }, diff --git a/extensions/agentmail/src/filtering.ts b/extensions/agentmail/src/filtering.ts index 3b5b9bb3c..c56c765ff 100644 --- a/extensions/agentmail/src/filtering.ts +++ b/extensions/agentmail/src/filtering.ts @@ -2,43 +2,24 @@ import type { AgentMailClient } from "agentmail"; import type { AgentMailConfig } from "./utils.js"; -/** - * Checks if a sender email matches any entry in a list. - * Entries can be exact email addresses or domains. - */ +/** Checks if a sender email matches any entry in a list (exact email or domain). */ export function matchesList(senderEmail: string, list: string[]): boolean { - if (!list || list.length === 0) return false; - - const normalizedSender = senderEmail.toLowerCase().trim(); - const domain = normalizedSender.split("@")[1]; - - return list.some((entry) => { - const normalizedEntry = entry.toLowerCase().trim(); - // Match exact email or domain - return normalizedEntry === normalizedSender || normalizedEntry === domain; + if (!list?.length) return false; + const sender = senderEmail.toLowerCase().trim(); + const domain = sender.split("@")[1]; + return list.some((e) => { + const entry = e.toLowerCase().trim(); + return entry === sender || entry === domain; }); } -/** - * Determines if a sender is allowed based on allowFrom config. - * - * Logic: - * 1. If allowFrom is empty → allowed (open mode) - * 2. If sender matches allowFrom → allowed - * 3. Otherwise → not allowed - */ +/** Checks if sender is allowed: empty allowFrom = open mode (all allowed). */ export function checkSenderAllowed( senderEmail: string, - config: Pick, + config: Pick ): boolean { const { allowFrom = [] } = config; - - // Open mode: all senders allowed when allowFrom is empty - if (allowFrom.length === 0) { - return true; - } - - return matchesList(senderEmail, allowFrom); + return allowFrom.length === 0 || matchesList(senderEmail, allowFrom); } /** @@ -47,7 +28,7 @@ export function checkSenderAllowed( export async function labelMessageAllowed( client: AgentMailClient, inboxId: string, - messageId: string, + messageId: string ): Promise { await client.inboxes.messages.update(inboxId, messageId, { addLabels: ["allowed"], diff --git a/extensions/agentmail/src/monitor.ts b/extensions/agentmail/src/monitor.ts index f2122c1a5..66f91e504 100644 --- a/extensions/agentmail/src/monitor.ts +++ b/extensions/agentmail/src/monitor.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { registerPluginHttpRoute } from "clawdbot/plugin-sdk"; -import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js"; +import { resolveCredentials } from "./accounts.js"; import { getAgentMailClient } from "./client.js"; import { getAgentMailRuntime } from "./runtime.js"; import { checkSenderAllowed, labelMessageAllowed } from "./filtering.js"; @@ -16,30 +16,26 @@ export type MonitorAgentMailOptions = { }; // Runtime state tracking -const runtimeState = new Map< - string, - { - running: boolean; - lastStartAt: number | null; - lastStopAt: number | null; - lastError: string | null; - lastInboundAt?: number | null; - lastOutboundAt?: number | null; - } ->(); +type RuntimeState = { + running: boolean; + lastStartAt: number | null; + lastStopAt: number | null; + lastError: string | null; + lastInboundAt?: number | null; + lastOutboundAt?: number | null; +}; +const runtimeState = new Map(); +const defaultState: RuntimeState = { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, +}; -function recordState( - accountId: string, - state: Partial ? V : never> -) { +function recordState(accountId: string, state: Partial) { const key = `agentmail:${accountId}`; runtimeState.set(key, { - ...(runtimeState.get(key) ?? { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }), + ...(runtimeState.get(key) ?? defaultState), ...state, }); } @@ -87,27 +83,13 @@ export async function monitorAgentMailProvider( module: "agentmail-auto-reply", }); const logVerbose = (msg: string) => { - if (core.logging.shouldLogVerbose()) { - if (logger.debug) { - logger.debug(msg); - } else { - logger.info(msg); - } - } + if (core.logging.shouldLogVerbose()) (logger.debug ?? logger.info)(msg); }; const accountId = opts.accountId ?? "default"; - const account = resolveAgentMailAccount({ cfg, accountId }); - - if (!account.configured) { - logger.warn("AgentMail not configured (missing token or email address)"); - return; - } - const { apiKey, inboxId, webhookPath } = resolveCredentials(cfg); - if (!apiKey || !inboxId) { - logger.warn("AgentMail token or email address not found"); + logger.warn("AgentMail not configured (missing token or email address)"); return; } diff --git a/extensions/agentmail/src/onboarding.ts b/extensions/agentmail/src/onboarding.ts index 050f5a366..df621a05d 100644 --- a/extensions/agentmail/src/onboarding.ts +++ b/extensions/agentmail/src/onboarding.ts @@ -6,9 +6,10 @@ import type { import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; import { + DEFAULT_WEBHOOK_PATH, + extractPathFromUrl, resolveAgentMailAccount, resolveCredentials, - resolveDefaultAgentMailAccountId, } from "./accounts.js"; import type { AgentMailConfig, CoreConfig } from "./utils.js"; @@ -86,12 +87,9 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { }, configure: async ({ cfg, prompter, accountOverrides }) => { - const defaultAccountId = resolveDefaultAgentMailAccountId( - cfg as CoreConfig - ); const accountId = accountOverrides.agentmail ? normalizeAccountId(accountOverrides.agentmail) - : defaultAccountId; + : DEFAULT_ACCOUNT_ID; let next = cfg as MoltbotConfig; const account = resolveAgentMailAccount({ @@ -160,9 +158,6 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { // Apply config next = updateAgentMailConfig(next, { enabled: true, token, emailAddress }); - // Webhook configuration - const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail"; - // Ask if gateway has a public URL const hasPublicUrl = await prompter.confirm({ message: @@ -174,7 +169,6 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { let webhookPath = DEFAULT_WEBHOOK_PATH; if (hasPublicUrl) { - // Get the full public webhook URL webhookUrl = String( await prompter.text({ message: "Full webhook URL", @@ -185,21 +179,13 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { if ( !trimmed.startsWith("http://") && !trimmed.startsWith("https://") - ) { + ) return "Must start with http:// or https://"; - } return undefined; }, }) ).trim(); - - // Derive path from URL - try { - const parsed = new URL(webhookUrl); - webhookPath = parsed.pathname || DEFAULT_WEBHOOK_PATH; - } catch { - webhookPath = DEFAULT_WEBHOOK_PATH; - } + webhookPath = extractPathFromUrl(webhookUrl) ?? DEFAULT_WEBHOOK_PATH; // Auto-register webhook with AgentMail try { @@ -349,22 +335,21 @@ async function promptForNewInbox( await prompter.note(`Your new inbox: ${emailAddress}`, "Inbox Created"); return emailAddress; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const isConflict = - message.toLowerCase().includes("already") || - message.toLowerCase().includes("taken") || - message.toLowerCase().includes("exists") || - message.includes("409"); - - if (isConflict) { + const msg = err instanceof Error ? err.message : String(err); + const lower = msg.toLowerCase(); + if ( + lower.includes("already") || + lower.includes("taken") || + lower.includes("exists") || + msg.includes("409") + ) { await prompter.note( `${targetEmail} is already taken. Please try a different address.`, "Address Unavailable" ); continue; } - - throw new Error(`Failed to create inbox: ${message}`); + throw new Error(`Failed to create inbox: ${msg}`); } } } diff --git a/extensions/agentmail/src/thread.ts b/extensions/agentmail/src/thread.ts index 30f37dff3..cfa672abf 100644 --- a/extensions/agentmail/src/thread.ts +++ b/extensions/agentmail/src/thread.ts @@ -16,62 +16,39 @@ export function extractMessageBody( return msg.extractedText ?? msg.extractedHtml ?? msg.text ?? msg.html ?? ""; } -/** - * Formats thread-level metadata. - */ function formatThreadHeader(thread: Thread): string { - const lines: string[] = []; - - if (thread.subject) { - lines.push(`Subject: ${thread.subject}`); - } - - lines.push(`Senders: ${thread.senders.join(", ")}`); - lines.push(`Recipients: ${thread.recipients.join(", ")}`); - lines.push(`Messages: ${thread.messageCount}`); - - return lines.join("\n"); + return [ + thread.subject && `Subject: ${thread.subject}`, + `Senders: ${thread.senders.join(", ")}`, + `Recipients: ${thread.recipients.join(", ")}`, + `Messages: ${thread.messageCount}`, + ] + .filter(Boolean) + .join("\n"); } -/** - * Formats recipients for a message (to, cc, bcc). - */ function formatMessageRecipients(msg: Message): string { - const parts: string[] = [`To: ${msg.to.join(", ")}`]; - - if (msg.cc?.length) { - parts.push(`Cc: ${msg.cc.join(", ")}`); - } - - if (msg.bcc?.length) { - parts.push(`Bcc: ${msg.bcc.join(", ")}`); - } - - return parts.join("\n"); + return [ + `To: ${msg.to.join(", ")}`, + msg.cc?.length && `Cc: ${msg.cc.join(", ")}`, + msg.bcc?.length && `Bcc: ${msg.bcc.join(", ")}`, + ] + .filter(Boolean) + .join("\n"); } -/** - * Formats a single message for context display. - */ function formatMessage(msg: Message): string { - const recipients = formatMessageRecipients(msg); const attachments = formatAttachments(msg.attachments); - const body = extractMessageBody(msg); - const timestamp = formatUtcDate(msg.createdAt); - - const parts = [ - `--- ${timestamp} ---`, + return [ + `--- ${formatUtcDate(msg.createdAt)} ---`, `From: ${msg.from}`, - recipients, - ]; - - if (attachments) { - parts.push(attachments); - } - - parts.push("", body); - - return parts.join("\n"); + formatMessageRecipients(msg), + attachments, + "", + extractMessageBody(msg), + ] + .filter(Boolean) + .join("\n"); } /**