diff --git a/extensions/agentmail/CHANGELOG.md b/extensions/agentmail/CHANGELOG.md index b5d304710..e8823207b 100644 --- a/extensions/agentmail/CHANGELOG.md +++ b/extensions/agentmail/CHANGELOG.md @@ -6,7 +6,7 @@ - Initial AgentMail plugin release - Email channel integration via AgentMail API -- Webhook-based inbound message handling +- WebSocket-based real-time message handling (no public URL required) - Full thread context fetching for conversation history - Sender allowFrom filtering (consistent with other channels) - Attachment metadata in thread context diff --git a/extensions/agentmail/README.md b/extensions/agentmail/README.md index ad24d8cdb..ceac288dc 100644 --- a/extensions/agentmail/README.md +++ b/extensions/agentmail/README.md @@ -39,16 +39,9 @@ Or via config: } ``` -## Webhook Setup - -Register a webhook in the AgentMail dashboard: - -- **URL:** `https://your-gateway-host:port/webhooks/agentmail` -- **Event:** `message.received` - ## Features -- Webhook-based inbound email handling +- WebSocket-based real-time email handling (no public URL required) - Full thread context for conversation history - Sender allowFrom filtering - Attachment metadata with on-demand download URLs diff --git a/extensions/agentmail/src/accounts.test.ts b/extensions/agentmail/src/accounts.test.ts index 6eee0928b..3072e4ac3 100644 --- a/extensions/agentmail/src/accounts.test.ts +++ b/extensions/agentmail/src/accounts.test.ts @@ -16,7 +16,6 @@ describe("resolveCredentials", () => { const result = resolveCredentials(cfg, {}); expect(result.apiKey).toBe("am_config_token"); expect(result.inboxId).toBe("inbox@agentmail.to"); - expect(result.webhookPath).toBe("/webhooks/agentmail"); }); it("falls back to environment variables", () => { @@ -48,93 +47,12 @@ describe("resolveCredentials", () => { expect(result.inboxId).toBe("config@agentmail.to"); }); - it("resolves custom webhookPath from config", () => { - const cfg: CoreConfig = { - channels: { - agentmail: { - webhookPath: "/custom/path", - }, - }, - }; - const result = resolveCredentials(cfg, {}); - expect(result.webhookPath).toBe("/custom/path"); - }); - - it("resolves webhookPath from env", () => { - const cfg: CoreConfig = {}; - const env = { - AGENTMAIL_WEBHOOK_PATH: "/env/webhook", - }; - const result = resolveCredentials(cfg, env); - expect(result.webhookPath).toBe("/env/webhook"); - }); - it("returns undefined for missing credentials", () => { const cfg: CoreConfig = {}; const result = resolveCredentials(cfg, {}); expect(result.apiKey).toBeUndefined(); expect(result.inboxId).toBeUndefined(); }); - - it("resolves webhookUrl from config", () => { - const cfg: CoreConfig = { - channels: { - agentmail: { - webhookUrl: "https://my-gateway.ngrok.io", - }, - }, - }; - const result = resolveCredentials(cfg, {}); - expect(result.webhookUrl).toBe("https://my-gateway.ngrok.io"); - }); - - it("resolves webhookUrl from env", () => { - const cfg: CoreConfig = {}; - const env = { - 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" - ); - }); - - it("derives webhookPath from webhookUrl", () => { - const cfg: CoreConfig = { - channels: { - agentmail: { - webhookUrl: "https://my-gateway.ngrok.io/custom/path", - }, - }, - }; - const result = resolveCredentials(cfg, {}); - expect(result.webhookPath).toBe("/custom/path"); - }); - - it("uses default webhookPath when URL has no path", () => { - const cfg: CoreConfig = { - channels: { - agentmail: { - webhookUrl: "https://my-gateway.ngrok.io", - }, - }, - }; - const result = resolveCredentials(cfg, {}); - expect(result.webhookPath).toBe("/webhooks/agentmail"); - }); - - it("explicit webhookPath takes precedence over URL-derived path", () => { - const cfg: CoreConfig = { - channels: { - agentmail: { - webhookUrl: "https://my-gateway.ngrok.io/url-path", - webhookPath: "/explicit-path", - }, - }, - }; - const result = resolveCredentials(cfg, {}); - expect(result.webhookPath).toBe("/explicit-path"); - }); }); describe("resolveAgentMailAccount", () => { diff --git a/extensions/agentmail/src/accounts.ts b/extensions/agentmail/src/accounts.ts index c158f023d..6d5aafb28 100644 --- a/extensions/agentmail/src/accounts.ts +++ b/extensions/agentmail/src/accounts.ts @@ -6,54 +6,24 @@ import type { ResolvedAgentMailAccount, } from "./utils.js"; -/** Resolved AgentMail credentials and paths. */ +/** Resolved AgentMail credentials. */ export type ResolvedAgentMailCredentials = { apiKey?: string; inboxId?: string; - webhookUrl?: string; - webhookPath: string; }; -export const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail"; - -/** Extracts the path from a URL string. Returns undefined if just root "/". */ -export function extractPathFromUrl(url: string): string | undefined { - try { - const parsed = new URL(url); - // Return undefined if just root path "/" - use default instead - if (!parsed.pathname || parsed.pathname === "/") { - return undefined; - } - return parsed.pathname; - } catch { - return undefined; - } -} - /** * Resolves AgentMail credentials from config and environment. * Maps user-facing keys (token, emailAddress) to SDK names (apiKey, inboxId). - * Derives webhookPath from webhookUrl if not explicitly set. */ export function resolveCredentials( cfg: CoreConfig, env: Record = process.env ): ResolvedAgentMailCredentials { const base = cfg.channels?.agentmail ?? {}; - const webhookUrl = base.webhookUrl || env.AGENTMAIL_WEBHOOK_URL; - - // Derive path from URL if not explicitly set - let webhookPath = base.webhookPath || env.AGENTMAIL_WEBHOOK_PATH; - if (!webhookPath && webhookUrl) { - webhookPath = extractPathFromUrl(webhookUrl); - } - webhookPath = webhookPath || DEFAULT_WEBHOOK_PATH; - return { apiKey: base.token || env.AGENTMAIL_TOKEN, inboxId: base.emailAddress || env.AGENTMAIL_EMAIL_ADDRESS, - webhookUrl, - webhookPath, }; } diff --git a/extensions/agentmail/src/channel.ts b/extensions/agentmail/src/channel.ts index cf5bf6127..8cc272621 100644 --- a/extensions/agentmail/src/channel.ts +++ b/extensions/agentmail/src/channel.ts @@ -59,14 +59,7 @@ export const agentmailPlugin: ChannelPlugin = { cfg: cfg as CoreConfig, sectionKey: "agentmail", accountId, - clearBaseFields: [ - "name", - "token", - "emailAddress", - "webhookUrl", - "webhookPath", - "allowFrom", - ], + clearBaseFields: ["name", "token", "emailAddress", "allowFrom"], }), isConfigured: (account) => account.configured, describeAccount: (account) => ({ @@ -136,18 +129,9 @@ export const agentmailPlugin: ChannelPlugin = { 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(); + if (input.emailAddress?.trim()) updates.emailAddress = input.emailAddress.trim(); } - return { - ...cfg, - channels: { - ...(cfg as CoreConfig).channels, - agentmail: { ...existing, ...updates }, - }, - }; + return { ...cfg, channels: { ...(cfg as CoreConfig).channels, agentmail: { ...existing, ...updates } } }; }, }, diff --git a/extensions/agentmail/src/config-schema.ts b/extensions/agentmail/src/config-schema.ts index df0674013..8910d346e 100644 --- a/extensions/agentmail/src/config-schema.ts +++ b/extensions/agentmail/src/config-schema.ts @@ -13,10 +13,6 @@ export const AgentMailConfigSchema = z.object({ token: z.string().optional(), /** AgentMail inbox email address to monitor (required). */ emailAddress: z.string().optional(), - /** Full public webhook URL (e.g., https://my-gateway.ngrok.io/webhooks/agentmail). */ - webhookUrl: z.string().optional(), - /** Local webhook path (default: /webhooks/agentmail). Derived from webhookUrl if not set. */ - webhookPath: z.string().optional(), /** Allowed sender emails/domains. Empty = allow all. */ allowFrom: z.array(z.string()).optional(), }); diff --git a/extensions/agentmail/src/monitor.ts b/extensions/agentmail/src/monitor.ts index 66f91e504..1283aac81 100644 --- a/extensions/agentmail/src/monitor.ts +++ b/extensions/agentmail/src/monitor.ts @@ -1,6 +1,4 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; - -import { registerPluginHttpRoute } from "clawdbot/plugin-sdk"; +import type { AgentMail } from "agentmail"; import { resolveCredentials } from "./accounts.js"; import { getAgentMailClient } from "./client.js"; @@ -8,7 +6,7 @@ import { getAgentMailRuntime } from "./runtime.js"; import { checkSenderAllowed, labelMessageAllowed } from "./filtering.js"; import { extractMessageBody, fetchFormattedThread } from "./thread.js"; import { parseEmailFromAddress, parseNameFromAddress } from "./utils.js"; -import type { CoreConfig, MessageReceivedEvent } from "./utils.js"; +import type { CoreConfig } from "./utils.js"; export type MonitorAgentMailOptions = { accountId?: string | null; @@ -16,28 +14,13 @@ export type MonitorAgentMailOptions = { }; // Runtime state tracking -type RuntimeState = { - 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, -}; +const defaultState: RuntimeState = { running: false, lastStartAt: null, lastStopAt: null, lastError: null }; function recordState(accountId: string, state: Partial) { const key = `agentmail:${accountId}`; - runtimeState.set(key, { - ...(runtimeState.get(key) ?? defaultState), - ...state, - }); + runtimeState.set(key, { ...(runtimeState.get(key) ?? defaultState), ...state }); } /** Returns runtime state for status checks. */ @@ -45,28 +28,14 @@ export function getAgentMailRuntimeState(accountId: string) { return runtimeState.get(`agentmail:${accountId}`); } -// HTTP helpers -async function readBody(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk); - return Buffer.concat(chunks).toString("utf-8"); -} - -function sendJson(res: ServerResponse, status: number, data: unknown) { - res.writeHead(status, { "Content-Type": "application/json" }); - res.end(JSON.stringify(data)); -} - -/** Builds message body from webhook payload as fallback when API fetch fails. */ -function buildFallbackBody( - message: NonNullable -): string { +/** Builds message body from event as fallback when thread fetch fails. */ +function buildFallbackBody(message: AgentMail.messages.Message): string { const subject = message.subject ? `Subject: ${message.subject}\n\n` : ""; return `${subject}${extractMessageBody(message)}`; } /** - * Main monitor function that sets up webhook handling for AgentMail. + * Main monitor function that sets up WebSocket connection to AgentMail. */ export async function monitorAgentMailProvider( opts: MonitorAgentMailOptions = {} @@ -79,15 +48,13 @@ export async function monitorAgentMailProvider( return; } - const logger = core.logging.getChildLogger({ - module: "agentmail-auto-reply", - }); + const logger = core.logging.getChildLogger({ module: "agentmail-monitor" }); const logVerbose = (msg: string) => { if (core.logging.shouldLogVerbose()) (logger.debug ?? logger.info)(msg); }; const accountId = opts.accountId ?? "default"; - const { apiKey, inboxId, webhookPath } = resolveCredentials(cfg); + const { apiKey, inboxId } = resolveCredentials(cfg); if (!apiKey || !inboxId) { logger.warn("AgentMail not configured (missing token or email address)"); return; @@ -96,52 +63,58 @@ export async function monitorAgentMailProvider( const client = getAgentMailClient(apiKey); const allowFrom = agentmailConfig?.allowFrom ?? []; - recordState(accountId, { - running: true, - lastStartAt: Date.now(), - lastError: null, - }); - logger.info(`AgentMail: starting monitor for ${inboxId}`); + recordState(accountId, { running: true, lastStartAt: Date.now(), lastError: null }); + logger.info(`AgentMail: connecting WebSocket for ${inboxId}`); - /** - * Handles incoming webhook requests from AgentMail. - */ - const handleWebhook = async (req: IncomingMessage, res: ServerResponse) => { - try { - if (req.method !== "POST") { - res.writeHead(405, { "Content-Type": "text/plain" }); - res.end("Method Not Allowed"); + let socket: Awaited> | null = null; + let connectionCount = 0; + + const subscribe = () => { + socket?.sendSubscribe({ + type: "subscribe", + inboxIds: [inboxId], + eventTypes: ["message.received"], + }); + }; + + try { + socket = await client.websockets.connect(); + + socket.on("open", () => { + connectionCount++; + const isReconnect = connectionCount > 1; + logger.info(`AgentMail: WebSocket ${isReconnect ? "reconnected" : "connected"}, subscribing to ${inboxId}`); + subscribe(); + if (isReconnect) { + recordState(accountId, { lastError: null }); // Clear error on successful reconnect + } + }); + + socket.on("message", async (event) => { + if (event.type === "subscribed") { + const sub = event as AgentMail.Subscribed; + logger.info(`AgentMail: subscribed to ${sub.inboxIds?.join(", ") ?? "inbox"}`); return; } - const body = await readBody(req); - const payload = JSON.parse(body) as { eventType?: string }; - // Only handle message.received events - if (payload.eventType !== "message.received") { - return sendJson(res, 200, { ok: true, ignored: true }); - } + if (event.type !== "event") return; + const msgEvent = event as AgentMail.MessageReceivedEvent; + if (msgEvent.eventType !== "message.received") return; - const event = payload as MessageReceivedEvent; - const message = event.message; - if (!message) { - return sendJson(res, 200, { ok: true, ignored: true }); - } + const message = msgEvent.message; + if (!message) return; - // Parse sender email from "from" string (format: "Display Name " or "email") const senderEmail = parseEmailFromAddress(message.from); - logVerbose(`agentmail: received message from ${senderEmail}`); // Apply sender filtering - const allowed = checkSenderAllowed(senderEmail, { allowFrom }); - - if (!allowed) { + if (!checkSenderAllowed(senderEmail, { allowFrom })) { logVerbose(`agentmail: sender ${senderEmail} not in allowFrom`); - return sendJson(res, 200, { ok: true, filtered: true }); + return; } - // Label message as allowed (best effort - don't fail if labeling fails) + // Label message as allowed (best effort) try { await labelMessageAllowed(client, inboxId, message.messageId); } catch (labelErr) { @@ -150,45 +123,25 @@ export async function monitorAgentMailProvider( recordState(accountId, { lastInboundAt: Date.now() }); - // Fetch the full thread from API (handles webhook size limits for large messages) - const threadBody = await fetchFormattedThread( - client, - inboxId, - message.threadId - ); - - // Extract current message text for RawBody/CommandBody + // Fetch the full thread from API + const threadBody = await fetchFormattedThread(client, inboxId, message.threadId); const messageBody = extractMessageBody(message); - - // Fall back to webhook payload if thread fetch fails const fullBody = threadBody || buildFallbackBody(message); // Resolve routing const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "agentmail", - peer: { - kind: "dm", - id: senderEmail, - }, + peer: { kind: "dm", id: senderEmail }, }); const senderName = parseNameFromAddress(message.from); const timestamp = new Date(message.timestamp).getTime(); // Format envelope - const storePath = core.channel.session.resolveStorePath( - cfg.session?.store, - { - agentId: route.agentId, - } - ); - const envelopeOptions = - core.channel.reply.resolveEnvelopeFormatOptions(cfg); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ storePath, sessionKey: route.sessionKey }); const formattedBody = core.channel.reply.formatAgentEnvelope({ channel: "Email", from: senderName, @@ -234,9 +187,7 @@ export async function monitorAgentMailProvider( to: inboxId, accountId: route.accountId, }, - onRecordError: (err) => { - logger.warn(`Failed updating session meta: ${String(err)}`); - }, + onRecordError: (err) => logger.warn(`Failed updating session meta: ${String(err)}`), }); const preview = messageBody.slice(0, 200).replace(/\n/g, "\\n"); @@ -244,80 +195,64 @@ export async function monitorAgentMailProvider( const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - humanDelay: core.channel.reply.resolveHumanDelayConfig( - cfg, - route.agentId - ), + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { - // Import outbound dynamically to avoid circular deps const { sendAgentMailReply } = await import("./outbound.js"); const text = payload.text ?? ""; - if (!text) return; // Skip empty replies - await sendAgentMailReply({ - client, - inboxId, - messageId: message.messageId, - text, - }); + if (!text) return; + await sendAgentMailReply({ client, inboxId, messageId: message.messageId, text }); recordState(accountId, { lastOutboundAt: Date.now() }); }, - onError: (err, info) => { - logger.error(`agentmail ${info.kind} reply failed: ${String(err)}`); - }, + onError: (err, info) => logger.error(`agentmail ${info.kind} reply failed: ${String(err)}`), }); - const { queuedFinal, counts } = - await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions, - }); + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); markDispatchIdle(); if (queuedFinal) { - logVerbose( - `agentmail: delivered ${counts.final} reply(ies) to ${senderEmail}` - ); + logVerbose(`agentmail: delivered ${counts.final} reply(ies) to ${senderEmail}`); core.system.enqueueSystemEvent(`Email from ${senderName}: ${preview}`, { sessionKey: route.sessionKey, contextKey: `agentmail:message:${message.messageId}`, }); } + }); - sendJson(res, 200, { ok: true }); - } catch (err) { - logger.error(`agentmail webhook handler failed: ${String(err)}`); - sendJson(res, 500, { ok: false, error: String(err) }); - } - }; + socket.on("error", (error) => { + logger.error(`AgentMail WebSocket error: ${String(error)}`); + recordState(accountId, { lastError: String(error) }); + }); - // Register the webhook route - const unregisterHttp = registerPluginHttpRoute({ - path: webhookPath, - pluginId: "agentmail", - accountId, - log: logVerbose, - handler: handleWebhook, - }); + socket.on("close", (event) => { + // SDK's ReconnectingWebSocket will auto-reconnect (default 30 attempts) + // On reconnect, "open" fires again and we resubscribe + logger.warn(`AgentMail: WebSocket closed (code: ${event.code}), will attempt reconnect`); + }); - logger.info(`AgentMail: webhook registered at ${webhookPath}`); + // Wait for abort signal + await new Promise((resolve) => { + const onAbort = () => { + logVerbose("agentmail: stopping monitor"); + socket?.close(); + recordState(accountId, { running: false, lastStopAt: Date.now() }); + resolve(); + }; - // Wait for abort signal - await new Promise((resolve) => { - const onAbort = () => { - logVerbose("agentmail: stopping monitor"); - unregisterHttp(); - recordState(accountId, { running: false, lastStopAt: Date.now() }); - resolve(); - }; + if (opts.abortSignal?.aborted) { + onAbort(); + return; + } - if (opts.abortSignal?.aborted) { - onAbort(); - return; - } - - opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); + opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + logger.error(`AgentMail WebSocket connection failed: ${String(err)}`); + recordState(accountId, { running: false, lastError: String(err), lastStopAt: Date.now() }); + } } diff --git a/extensions/agentmail/src/onboarding.test.ts b/extensions/agentmail/src/onboarding.test.ts index 08ecd2641..31535209c 100644 --- a/extensions/agentmail/src/onboarding.test.ts +++ b/extensions/agentmail/src/onboarding.test.ts @@ -87,34 +87,4 @@ describe("updateAgentMailConfig", () => { "example.org", ]); }); - - it("sets webhookPath", () => { - const cfg = {} as never; - const result: AnyConfig = updateAgentMailConfig(cfg, { - webhookPath: "/custom/webhook", - }); - expect(result.channels?.agentmail?.webhookPath).toBe("/custom/webhook"); - }); - - it("sets webhookUrl", () => { - const cfg = {} as never; - const result: AnyConfig = updateAgentMailConfig(cfg, { - webhookUrl: "https://my-gateway.ngrok.io", - }); - expect(result.channels?.agentmail?.webhookUrl).toBe( - "https://my-gateway.ngrok.io" - ); - }); - - it("sets both webhookUrl and webhookPath", () => { - const cfg = {} as never; - const result: AnyConfig = updateAgentMailConfig(cfg, { - webhookUrl: "https://gateway.example.com", - webhookPath: "/hooks/email", - }); - expect(result.channels?.agentmail?.webhookUrl).toBe( - "https://gateway.example.com" - ); - expect(result.channels?.agentmail?.webhookPath).toBe("/hooks/email"); - }); }); diff --git a/extensions/agentmail/src/onboarding.ts b/extensions/agentmail/src/onboarding.ts index 8eef09014..079a05d13 100644 --- a/extensions/agentmail/src/onboarding.ts +++ b/extensions/agentmail/src/onboarding.ts @@ -5,22 +5,14 @@ import type { } from "clawdbot/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; -import { - DEFAULT_WEBHOOK_PATH, - extractPathFromUrl, - resolveAgentMailAccount, - resolveCredentials, -} from "./accounts.js"; +import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js"; import type { AgentMailConfig, CoreConfig } from "./utils.js"; const channel = "agentmail" as const; const DEFAULT_DOMAIN = "agentmail.to"; /** Parses input into username and domain. Supports "user" or "user@domain". */ -export function parseInboxInput(input: string): { - username: string; - domain: string; -} { +export function parseInboxInput(input: string): { username: string; domain: string } { const trimmed = input.trim().toLowerCase(); if (trimmed.includes("@")) { const [username, domain] = trimmed.split("@"); @@ -36,12 +28,8 @@ async function createInbox( domain: string, displayName?: string ): Promise { - const inbox = await client.inboxes.create({ - username, - domain, - displayName, - }); - return inbox.inboxId; // inboxId is the email address + const inbox = await client.inboxes.create({ username, domain, displayName }); + return inbox.inboxId; } /** Lists existing inboxes for the user. */ @@ -59,13 +47,7 @@ export function updateAgentMailConfig( const agentmail = (channels.agentmail ?? {}) as AgentMailConfig; return { ...cfg, - channels: { - ...channels, - agentmail: { - ...agentmail, - ...updates, - }, - }, + channels: { ...channels, agentmail: { ...agentmail, ...updates } }, } as MoltbotConfig; } @@ -78,9 +60,7 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { return { channel, configured, - statusLines: [ - `AgentMail: ${configured ? `configured (${inboxId})` : "needs token"}`, - ], + statusLines: [`AgentMail: ${configured ? `configured (${inboxId})` : "needs token"}`], selectionHint: configured ? "configured" : "not configured", quickstartScore: configured ? 1 : 5, }; @@ -92,16 +72,11 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { : DEFAULT_ACCOUNT_ID; let next = cfg as MoltbotConfig; - const account = resolveAgentMailAccount({ - cfg: next as CoreConfig, - accountId, - }); + const account = resolveAgentMailAccount({ cfg: next as CoreConfig, accountId }); const { apiKey, inboxId } = resolveCredentials(next as CoreConfig); const configured = Boolean(apiKey && inboxId); - const canUseEnv = - accountId === DEFAULT_ACCOUNT_ID && - Boolean(process.env.AGENTMAIL_TOKEN?.trim()); + const canUseEnv = accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env.AGENTMAIL_TOKEN?.trim()); // If env var token is available and not already configured, offer to use it if (canUseEnv && !account.configured) { @@ -110,13 +85,9 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (useEnv) { - const envToken = process.env.AGENTMAIL_TOKEN!.trim(); - const client = new AgentMailClient({ apiKey: envToken }); + const client = new AgentMailClient({ apiKey: process.env.AGENTMAIL_TOKEN!.trim() }); const emailAddress = await selectOrCreateInbox(client, prompter); - - return { - cfg: updateAgentMailConfig(next, { enabled: true, emailAddress }), - }; + return { cfg: updateAgentMailConfig(next, { enabled: true, emailAddress }) }; } } @@ -126,9 +97,7 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { message: `AgentMail already configured (${inboxId}). Keep current settings?`, initialValue: true, }); - if (keep) { - return { cfg: next }; - } + if (keep) return { cfg: next }; } // Show help @@ -137,7 +106,7 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { "AgentMail is free to use at https://agentmail.to", "", "Get your API token from the dashboard, then we'll help you", - "create an inbox.", + "create an inbox. No webhooks needed - we use WebSockets!", ].join("\n"), "AgentMail Setup" ); @@ -158,64 +127,6 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { // Apply config next = updateAgentMailConfig(next, { enabled: true, token, emailAddress }); - // Ask if gateway has a public URL - const hasPublicUrl = await prompter.confirm({ - message: - "Does your gateway have a public URL? (for automatic webhook setup)", - initialValue: false, - }); - - let webhookUrl: string | undefined; - let webhookPath = DEFAULT_WEBHOOK_PATH; - - if (hasPublicUrl) { - webhookUrl = String( - await prompter.text({ - message: "Full webhook URL", - placeholder: `https://my-gateway.ngrok.io${DEFAULT_WEBHOOK_PATH}`, - validate: (v) => { - const trimmed = v?.trim(); - if (!trimmed) return "Required"; - if ( - !trimmed.startsWith("http://") && - !trimmed.startsWith("https://") - ) - return "Must start with http:// or https://"; - return undefined; - }, - }) - ).trim(); - webhookPath = extractPathFromUrl(webhookUrl) ?? DEFAULT_WEBHOOK_PATH; - - // Auto-register webhook with AgentMail - try { - await client.webhooks.create({ - url: webhookUrl, - eventTypes: ["message.received"], - clientId: `clawdbot-${emailAddress}`, // Idempotent per inbox - }); - await prompter.note( - `Webhook registered: ${webhookUrl}`, - "Webhook Created" - ); - } catch (err) { - // Webhook may already exist or API error - show warning but continue - await prompter.note( - [ - `Could not auto-register webhook: ${String(err)}`, - "", - "You may need to configure it manually in the AgentMail dashboard:", - ` URL: ${webhookUrl}`, - " Event: message.received", - ].join("\n"), - "Webhook Warning" - ); - } - } - - // Save webhook config (webhookPath derived from URL or default) - next = updateAgentMailConfig(next, { webhookUrl, webhookPath }); - // Ask about allowFrom const addAllowFrom = await prompter.confirm({ message: "Add senders to allowFrom? (Empty = allow all)", @@ -225,47 +136,23 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = { if (addAllowFrom) { const entry = String( await prompter.text({ - message: - "Email or domain to allow (e.g., user@example.com or example.com)", + message: "Email or domain to allow (e.g., user@example.com or example.com)", }) ).trim(); - if (entry) { - const existing = - (next as CoreConfig).channels?.agentmail?.allowFrom ?? []; + const existing = (next as CoreConfig).channels?.agentmail?.allowFrom ?? []; next = updateAgentMailConfig(next, { allowFrom: [...existing, entry] }); } } - // Show manual webhook instructions only if we didn't auto-register - if (!webhookUrl) { - await prompter.note( - [ - "Configure the webhook in your AgentMail dashboard:", - ` URL: https://your-gateway${webhookPath}`, - " Event: message.received", - "", - "The gateway must be publicly accessible for webhooks to work.", - "Without this, Moltbot won't receive incoming emails.", - ].join("\n"), - "Webhook Setup Required" - ); - } - return { cfg: next }; }, }; -type Prompter = Parameters< - ChannelOnboardingAdapter["configure"] ->[0]["prompter"]; +type Prompter = Parameters[0]["prompter"]; /** Prompts user to select an existing inbox or create a new one. */ -async function selectOrCreateInbox( - client: AgentMailClient, - prompter: Prompter -): Promise { - // Check for existing inboxes +async function selectOrCreateInbox(client: AgentMailClient, prompter: Prompter): Promise { let existingInboxes: string[] = []; try { existingInboxes = await listInboxes(client); @@ -278,26 +165,15 @@ async function selectOrCreateInbox( ...existingInboxes.map((email) => ({ value: email, label: email })), { value: "__create__", label: "Create a new inbox" }, ]; - - const selection = await prompter.select({ - message: "Select an inbox or create a new one", - options: choices, - }); - - if (selection !== "__create__") { - return selection as string; - } + const selection = await prompter.select({ message: "Select an inbox or create a new one", options: choices }); + if (selection !== "__create__") return selection as string; } return promptForNewInbox(client, prompter); } /** Prompts for inbox address and creates it, with retry on conflict. */ -async function promptForNewInbox( - client: AgentMailClient, - prompter: Prompter -): Promise { - // eslint-disable-next-line no-constant-condition +async function promptForNewInbox(client: AgentMailClient, prompter: Prompter): Promise { while (true) { const input = String( await prompter.text({ @@ -306,9 +182,8 @@ async function promptForNewInbox( validate: (v) => { if (!v?.trim()) return "Required"; const { username } = parseInboxInput(v); - if (!/^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$/.test(username)) { + if (!/^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$/.test(username)) return "Username must use lowercase letters, numbers, dots, underscores, or hyphens"; - } return undefined; }, }) @@ -317,36 +192,17 @@ async function promptForNewInbox( const { username, domain } = parseInboxInput(input); const targetEmail = `${username}@${domain}`; - const displayName = - String( - await prompter.text({ - message: "Display name (optional)", - placeholder: "My Agent", - }) - ).trim() || undefined; + const displayName = String(await prompter.text({ message: "Display name (optional)", placeholder: "My Agent" })).trim() || undefined; try { - const emailAddress = await createInbox( - client, - username, - domain, - displayName - ); + const emailAddress = await createInbox(client, username, domain, displayName); await prompter.note(`Your new inbox: ${emailAddress}`, "Inbox Created"); return emailAddress; } catch (err) { 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" - ); + 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: ${msg}`); diff --git a/extensions/agentmail/src/thread.ts b/extensions/agentmail/src/thread.ts index cfa672abf..30f603c3d 100644 --- a/extensions/agentmail/src/thread.ts +++ b/extensions/agentmail/src/thread.ts @@ -72,7 +72,7 @@ export async function fetchFormattedThread( return `${header}\n\n${messages}`; } catch { - // Caller handles fallback to webhook payload + // Caller handles fallback to event message return ""; } } diff --git a/extensions/agentmail/src/utils.ts b/extensions/agentmail/src/utils.ts index f2b7d2f67..f0de25833 100644 --- a/extensions/agentmail/src/utils.ts +++ b/extensions/agentmail/src/utils.ts @@ -31,9 +31,6 @@ export type ResolvedAgentMailAccount = { /** SDK Message type alias. */ export type Message = AgentMail.messages.Message; -/** Re-export SDK webhook event type. */ -export type MessageReceivedEvent = AgentMail.events.MessageReceivedEvent; - /** Formats a date as a UTC string. */ export function formatUtcDate(date: Date | string | number): string { return new Date(date).toUTCString().replace("GMT", "UTC");