Websockets
This commit is contained in:
parent
33871a8f99
commit
cabba2ef91
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 } } };
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user