openclaw/extensions/agentmail/src/onboarding.ts
2026-01-27 22:33:01 -08:00

205 lines
7.2 KiB
TypeScript

import { AgentMailClient } from "agentmail";
import type { ChannelOnboardingAdapter, MoltbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import { resolveAgentMailAccount } 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 } {
const trimmed = input.trim().toLowerCase();
if (trimmed.includes("@")) {
const [username, domain] = trimmed.split("@");
return { username, domain };
}
return { username: trimmed, domain: DEFAULT_DOMAIN };
}
/** Creates a new inbox via AgentMail API. */
async function createInbox(
client: AgentMailClient,
username: string,
domain: string,
displayName?: string
): Promise<string> {
const inbox = await client.inboxes.create({ username, domain, displayName });
return inbox.inboxId;
}
/** Lists existing inboxes for the user. */
async function listInboxes(client: AgentMailClient): Promise<string[]> {
const response = await client.inboxes.list();
return response.inboxes.map((i) => i.inboxId);
}
/** Helper to build config with agentmail channel updates. */
export function updateAgentMailConfig(
cfg: MoltbotConfig,
updates: Partial<AgentMailConfig>
): MoltbotConfig {
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const agentmail = (channels.agentmail ?? {}) as AgentMailConfig;
return {
...cfg,
channels: { ...channels, agentmail: { ...agentmail, ...updates } },
} as MoltbotConfig;
}
export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = resolveAgentMailAccount({ cfg: cfg as CoreConfig });
return {
channel,
configured: account.configured,
statusLines: [`AgentMail: ${account.configured ? `configured (${account.inboxId})` : "needs token"}`],
selectionHint: account.configured ? "configured" : "not configured",
quickstartScore: account.configured ? 1 : 5,
};
},
configure: async ({ cfg, prompter, accountOverrides }) => {
const accountId = accountOverrides.agentmail
? normalizeAccountId(accountOverrides.agentmail)
: DEFAULT_ACCOUNT_ID;
let next = cfg as MoltbotConfig;
const account = resolveAgentMailAccount({ cfg: next as CoreConfig, accountId });
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) {
const useEnv = await prompter.confirm({
message: "AGENTMAIL_TOKEN detected. Use env var?",
initialValue: true,
});
if (useEnv) {
const client = new AgentMailClient({ apiKey: process.env.AGENTMAIL_TOKEN!.trim() });
const emailAddress = await selectOrCreateInbox(client, prompter);
return { cfg: updateAgentMailConfig(next, { enabled: true, emailAddress }) };
}
}
// If already configured, ask to keep
if (account.configured) {
const keep = await prompter.confirm({
message: `AgentMail already configured (${account.inboxId}). Keep current settings?`,
initialValue: true,
});
if (keep) return { cfg: next };
}
// Show help
await prompter.note(
[
"AgentMail is free to use at https://agentmail.to",
"",
"Get your API token from the dashboard, then we'll help you",
"create an inbox. No webhooks needed - we use WebSockets!",
].join("\n"),
"AgentMail Setup"
);
// Prompt for token
const token = String(
await prompter.text({
message: "AgentMail API token",
placeholder: "am_...",
validate: (v) => (v?.trim() ? undefined : "Required"),
})
).trim();
// Create client and select/create inbox
const client = new AgentMailClient({ apiKey: token });
const emailAddress = await selectOrCreateInbox(client, prompter);
// Apply config
next = updateAgentMailConfig(next, { enabled: true, token, emailAddress });
// Ask about allowFrom
const addAllowFrom = await prompter.confirm({
message: "Add senders to allowFrom? (Empty = allow all)",
initialValue: false,
});
if (addAllowFrom) {
const entry = String(
await prompter.text({
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 ?? [];
next = updateAgentMailConfig(next, { allowFrom: [...existing, entry] });
}
}
return { cfg: next };
},
};
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> {
let existingInboxes: string[] = [];
try {
existingInboxes = await listInboxes(client);
} catch {
// API error - proceed with create flow
}
if (existingInboxes.length > 0) {
const choices = [
...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;
}
return promptForNewInbox(client, prompter);
}
/** Prompts for inbox address and creates it, with retry on conflict. */
async function promptForNewInbox(client: AgentMailClient, prompter: Prompter): Promise<string> {
while (true) {
const input = String(
await prompter.text({
message: "Inbox address (username or full email)",
placeholder: `my-agent or my-agent@${DEFAULT_DOMAIN}`,
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))
return "Username must use lowercase letters, numbers, dots, underscores, or hyphens";
return undefined;
},
})
).trim();
const { username, domain } = parseInboxInput(input);
const targetEmail = `${username}@${domain}`;
const displayName = String(await prompter.text({ message: "Display name (optional)", placeholder: "My Agent" })).trim() || undefined;
try {
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");
continue;
}
throw new Error(`Failed to create inbox: ${msg}`);
}
}
}