Webhook config

This commit is contained in:
Haakam Aujla 2026-01-27 13:45:54 -08:00
parent f399bdfab1
commit f0f5b51dd2
5 changed files with 87 additions and 62 deletions

View File

@ -81,8 +81,8 @@ Minimal config:
| `enabled` | boolean | Enable/disable the channel (default: true) |
| `token` | string | AgentMail API token (required) |
| `emailAddress` | string | AgentMail inbox email address to monitor (required) |
| `webhookUrl` | string | Gateway public base URL (e.g., `https://gw.ngrok.io`) |
| `webhookPath` | string | Custom webhook path (default: `/webhooks/agentmail`) |
| `webhookUrl` | string | Full public webhook URL (e.g., `https://gw.ngrok.io/webhooks/agentmail`) |
| `webhookPath` | string | Local webhook path (derived from URL or default: `/webhooks/agentmail`) |
| `allowFrom` | string[] | Allowed sender emails/domains (empty = allow all) |
## Sender Filtering

View File

@ -96,10 +96,47 @@ describe("resolveCredentials", () => {
it("resolves webhookUrl from env", () => {
const cfg: CoreConfig = {};
const env = {
AGENTMAIL_WEBHOOK_URL: "https://env-gateway.example.com",
AGENTMAIL_WEBHOOK_URL: "https://env-gateway.example.com/hooks/email",
};
const result = resolveCredentials(cfg, env);
expect(result.webhookUrl).toBe("https://env-gateway.example.com");
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");
});
});

View File

@ -31,21 +31,46 @@ export type ResolvedAgentMailCredentials = {
webhookPath: string;
};
const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail";
/** Extracts the path from a URL string. Returns undefined if just root "/". */
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: base.webhookUrl || env.AGENTMAIL_WEBHOOK_URL,
webhookPath:
base.webhookPath || env.AGENTMAIL_WEBHOOK_PATH || "/webhooks/agentmail",
webhookUrl,
webhookPath,
};
}

View File

@ -13,9 +13,9 @@ export const AgentMailConfigSchema = z.object({
token: z.string().optional(),
/** AgentMail inbox email address to monitor (required). */
emailAddress: z.string().optional(),
/** Public base URL of the gateway (e.g., https://my-gateway.ngrok.io). */
/** Full public webhook URL (e.g., https://my-gateway.ngrok.io/webhooks/agentmail). */
webhookUrl: z.string().optional(),
/** Custom webhook path for receiving emails (default: /webhooks/agentmail). */
/** 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

@ -173,11 +173,11 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
let webhookPath = DEFAULT_WEBHOOK_PATH;
if (hasPublicUrl) {
// Get the public base URL
const baseUrl = String(
// Get the full public webhook URL
webhookUrl = String(
await prompter.text({
message: "Gateway public URL",
placeholder: "https://my-gateway.ngrok.io",
message: "Full webhook URL",
placeholder: `https://my-gateway.ngrok.io${DEFAULT_WEBHOOK_PATH}`,
validate: (v) => {
const trimmed = v?.trim();
if (!trimmed) return "Required";
@ -190,42 +190,24 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
return undefined;
},
})
)
.trim()
.replace(/\/+$/, ""); // Remove trailing slashes
).trim();
// Ask for webhook path
const customPath = await prompter.confirm({
message: `Customize webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`,
initialValue: false,
});
if (customPath) {
const pathInput = String(
await prompter.text({
message: "Webhook path",
placeholder: DEFAULT_WEBHOOK_PATH,
})
).trim();
if (pathInput) {
webhookPath = pathInput.startsWith("/") ? pathInput : `/${pathInput}`;
}
// Derive path from URL
try {
const parsed = new URL(webhookUrl);
webhookPath = parsed.pathname || DEFAULT_WEBHOOK_PATH;
} catch {
webhookPath = DEFAULT_WEBHOOK_PATH;
}
webhookUrl = baseUrl;
// Auto-register webhook with AgentMail
try {
await client.webhooks.create({
url: `${baseUrl}${webhookPath}`,
url: webhookUrl,
eventTypes: ["message.received"],
clientId: `moltbot-${emailAddress}`, // Idempotent per inbox
clientId: `clawdbot-${emailAddress}`, // Idempotent per inbox
});
await prompter.note(
`Webhook registered: ${baseUrl}${webhookPath}`,
"Webhook Created"
);
await prompter.note(`Webhook registered: ${webhookUrl}`, "Webhook Created");
} catch (err) {
// Webhook may already exist or API error - show warning but continue
await prompter.note(
@ -233,34 +215,15 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
`Could not auto-register webhook: ${String(err)}`,
"",
"You may need to configure it manually in the AgentMail dashboard:",
` URL: ${baseUrl}${webhookPath}`,
` URL: ${webhookUrl}`,
" Event: message.received",
].join("\n"),
"Webhook Warning"
);
}
} else {
// No public URL - ask for path only and show manual instructions
const customPath = await prompter.confirm({
message: `Customize webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`,
initialValue: false,
});
if (customPath) {
const pathInput = String(
await prompter.text({
message: "Webhook path",
placeholder: DEFAULT_WEBHOOK_PATH,
})
).trim();
if (pathInput) {
webhookPath = pathInput.startsWith("/") ? pathInput : `/${pathInput}`;
}
}
}
// Save webhook config
// Save webhook config (webhookPath derived from URL or default)
next = updateAgentMailConfig(next, { webhookUrl, webhookPath });
// Ask about allowFrom