Websockets

This commit is contained in:
Haakam Aujla 2026-01-27 16:49:42 -08:00
parent 33871a8f99
commit cabba2ef91
11 changed files with 125 additions and 506 deletions

View File

@ -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

View File

@ -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

View File

@ -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", () => {

View File

@ -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<string, string | undefined> = 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,
};
}

View File

@ -59,14 +59,7 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
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<ResolvedAgentMailAccount> = {
const updates: Record<string, unknown> = { 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 } } };
},
},

View File

@ -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(),
});

View File

@ -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<string, RuntimeState>();
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<RuntimeState>) {
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<string> {
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<MessageReceivedEvent["message"]>
): 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<ReturnType<typeof client.websockets.connect>> | 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 <email>" 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<void>((resolve) => {
const onAbort = () => {
logVerbose("agentmail: stopping monitor");
socket?.close();
recordState(accountId, { running: false, lastStopAt: Date.now() });
resolve();
};
// Wait for abort signal
await new Promise<void>((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() });
}
}

View File

@ -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");
});
});

View File

@ -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<string> {
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<ChannelOnboardingAdapter["configure"]>[0]["prompter"];
/** Prompts user to select an existing inbox or create a new one. */
async function selectOrCreateInbox(
client: AgentMailClient,
prompter: Prompter
): Promise<string> {
// Check for existing inboxes
async function selectOrCreateInbox(client: AgentMailClient, prompter: Prompter): Promise<string> {
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<string> {
// eslint-disable-next-line no-constant-condition
async function promptForNewInbox(client: AgentMailClient, prompter: Prompter): Promise<string> {
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}`);

View File

@ -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 "";
}
}

View File

@ -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");