Consolidate

This commit is contained in:
Haakam Aujla 2026-01-27 15:01:48 -08:00
parent 4dbd7ab0ce
commit 258a4bbe49
7 changed files with 130 additions and 280 deletions

View File

@ -1,11 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js";
listAgentMailAccountIds,
resolveAgentMailAccount,
resolveCredentials,
resolveDefaultAgentMailAccountId,
} from "./accounts.js";
import type { CoreConfig } from "./utils.js"; import type { CoreConfig } from "./utils.js";
describe("resolveCredentials", () => { describe("resolveCredentials", () => {
@ -99,7 +94,9 @@ describe("resolveCredentials", () => {
AGENTMAIL_WEBHOOK_URL: "https://env-gateway.example.com/hooks/email", AGENTMAIL_WEBHOOK_URL: "https://env-gateway.example.com/hooks/email",
}; };
const result = resolveCredentials(cfg, env); const result = resolveCredentials(cfg, env);
expect(result.webhookUrl).toBe("https://env-gateway.example.com/hooks/email"); expect(result.webhookUrl).toBe(
"https://env-gateway.example.com/hooks/email"
);
}); });
it("derives webhookPath from webhookUrl", () => { it("derives webhookPath from webhookUrl", () => {
@ -218,19 +215,3 @@ describe("resolveAgentMailAccount", () => {
expect(result.name).toBe("Trimmed Name"); expect(result.name).toBe("Trimmed Name");
}); });
}); });
describe("listAgentMailAccountIds", () => {
it("returns default account", () => {
const cfg: CoreConfig = {};
const result = listAgentMailAccountIds(cfg);
expect(result).toEqual(["default"]);
});
});
describe("resolveDefaultAgentMailAccountId", () => {
it("returns default account id", () => {
const cfg: CoreConfig = {};
const result = resolveDefaultAgentMailAccountId(cfg);
expect(result).toBe("default");
});
});

View File

@ -6,22 +6,6 @@ import type {
ResolvedAgentMailAccount, ResolvedAgentMailAccount,
} from "./utils.js"; } from "./utils.js";
/**
* Lists all AgentMail account IDs.
* Currently supports only a single default account.
*/
export function listAgentMailAccountIds(_cfg: CoreConfig): string[] {
return [DEFAULT_ACCOUNT_ID];
}
/**
* Returns the default AgentMail account ID.
* Currently only supports a single default account.
*/
export function resolveDefaultAgentMailAccountId(_cfg: CoreConfig): string {
return DEFAULT_ACCOUNT_ID;
}
/** Resolved AgentMail credentials and paths. */ /** Resolved AgentMail credentials and paths. */
export type ResolvedAgentMailCredentials = { export type ResolvedAgentMailCredentials = {
apiKey?: string; apiKey?: string;
@ -30,10 +14,10 @@ export type ResolvedAgentMailCredentials = {
webhookPath: string; webhookPath: string;
}; };
const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail"; export const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail";
/** Extracts the path from a URL string. Returns undefined if just root "/". */ /** Extracts the path from a URL string. Returns undefined if just root "/". */
function extractPathFromUrl(url: string): string | undefined { export function extractPathFromUrl(url: string): string | undefined {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
// Return undefined if just root path "/" - use default instead // Return undefined if just root path "/" - use default instead

View File

@ -8,12 +8,7 @@ import {
type ChannelPlugin, type ChannelPlugin,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js";
listAgentMailAccountIds,
resolveAgentMailAccount,
resolveDefaultAgentMailAccountId,
resolveCredentials,
} from "./accounts.js";
import { getAgentMailClient } from "./client.js"; import { getAgentMailClient } from "./client.js";
import { AgentMailConfigSchema } from "./config-schema.js"; import { AgentMailConfigSchema } from "./config-schema.js";
import { agentmailOnboardingAdapter } from "./onboarding.js"; import { agentmailOnboardingAdapter } from "./onboarding.js";
@ -47,11 +42,10 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
configSchema: buildChannelConfigSchema(AgentMailConfigSchema), configSchema: buildChannelConfigSchema(AgentMailConfigSchema),
config: { config: {
listAccountIds: (cfg) => listAgentMailAccountIds(cfg as CoreConfig), listAccountIds: () => [DEFAULT_ACCOUNT_ID],
resolveAccount: (cfg, accountId) => resolveAccount: (cfg, accountId) =>
resolveAgentMailAccount({ cfg: cfg as CoreConfig, accountId }), resolveAgentMailAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => defaultAccountId: () => DEFAULT_ACCOUNT_ID,
resolveDefaultAgentMailAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({ setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig, cfg: cfg as CoreConfig,
@ -102,18 +96,10 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
"Add email addresses or domains to channels.agentmail.allowFrom", "Add email addresses or domains to channels.agentmail.allowFrom",
normalizeEntry: (raw) => raw.toLowerCase().trim(), normalizeEntry: (raw) => raw.toLowerCase().trim(),
}), }),
collectWarnings: ({ account }) => { collectWarnings: ({ account }) =>
const warnings: string[] = []; (account.config.allowFrom?.length ?? 0) === 0
const { allowFrom = [] } = account.config; ? ["- AgentMail: No allowFrom configured. All senders will be allowed."]
: [],
if (allowFrom.length === 0) {
warnings.push(
"- AgentMail: No allowFrom configured. All senders will be allowed."
);
}
return warnings;
},
}, },
messaging: { messaging: {
@ -147,25 +133,19 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
}, },
applyAccountConfig: ({ cfg, input }) => { applyAccountConfig: ({ cfg, input }) => {
const existing = (cfg as CoreConfig).channels?.agentmail ?? {}; const existing = (cfg as CoreConfig).channels?.agentmail ?? {};
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();
}
return { return {
...cfg, ...cfg,
channels: { channels: {
...(cfg as CoreConfig).channels, ...(cfg as CoreConfig).channels,
agentmail: { agentmail: { ...existing, ...updates },
...existing,
enabled: true,
...(input.useEnv
? {}
: {
...(input.token?.trim() ? { token: input.token.trim() } : {}),
...(input.emailAddress?.trim()
? { emailAddress: input.emailAddress.trim() }
: {}),
...(input.webhookPath?.trim()
? { webhookPath: input.webhookPath.trim() }
: {}),
}),
},
}, },
}; };
}, },
@ -182,54 +162,39 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
lastError: null, lastError: null,
}, },
collectStatusIssues: (accounts) => collectStatusIssues: (accounts) =>
accounts.flatMap((account) => { accounts
const lastError = .filter((a) => typeof a.lastError === "string" && a.lastError.trim())
typeof account.lastError === "string" ? account.lastError.trim() : ""; .map((a) => ({
if (!lastError) return []; channel: "agentmail",
return [ accountId: a.accountId,
{ kind: "runtime" as const,
channel: "agentmail", message: `Channel error: ${a.lastError}`,
accountId: account.accountId, })),
kind: "runtime", buildChannelSummary: ({ snapshot: s }) => ({
message: `Channel error: ${lastError}`, configured: s.configured ?? false,
}, emailAddress: s.emailAddress ?? null,
]; running: s.running ?? false,
}), lastStartAt: s.lastStartAt ?? null,
buildChannelSummary: ({ snapshot }) => ({ lastStopAt: s.lastStopAt ?? null,
configured: snapshot.configured ?? false, lastError: s.lastError ?? null,
emailAddress: snapshot.emailAddress ?? null, probe: s.probe,
running: snapshot.running ?? false, lastProbeAt: s.lastProbeAt ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}), }),
probeAccount: async ({ cfg }) => { probeAccount: async ({ cfg }) => {
try { try {
const { apiKey, inboxId } = resolveCredentials(cfg as CoreConfig); const { apiKey, inboxId } = resolveCredentials(cfg as CoreConfig);
if (!apiKey || !inboxId)
if (!apiKey || !inboxId) {
return { return {
ok: false, ok: false,
error: "Missing token or email address", error: "Missing token or email address",
elapsedMs: 0, elapsedMs: 0,
}; };
}
const start = Date.now(); const start = Date.now();
const client = getAgentMailClient(apiKey); const inbox = await getAgentMailClient(apiKey).inboxes.get(inboxId);
// Probe by getting inbox info
const inbox = await client.inboxes.get(inboxId);
const elapsedMs = Date.now() - start;
return { return {
ok: true, ok: true,
elapsedMs, elapsedMs: Date.now() - start,
meta: { meta: { inboxId: inbox.inboxId },
inboxId: inbox.inboxId, // inboxId is the email address
},
}; };
} catch (err) { } catch (err) {
return { return {
@ -239,40 +204,35 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
}; };
} }
}, },
buildAccountSnapshot: ({ account, runtime, probe }) => ({ buildAccountSnapshot: ({ account: a, runtime: r, probe }) => ({
accountId: account.accountId, accountId: a.accountId,
name: account.name, name: a.name,
enabled: account.enabled, enabled: a.enabled,
configured: account.configured, configured: a.configured,
emailAddress: account.inboxId, emailAddress: a.inboxId,
running: runtime?.running ?? false, running: r?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null, lastStartAt: r?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null, lastStopAt: r?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null, lastError: r?.lastError ?? null,
probe, probe,
lastProbeAt: runtime?.lastProbeAt ?? null, lastProbeAt: r?.lastProbeAt ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null, lastInboundAt: r?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null, lastOutboundAt: r?.lastOutboundAt ?? null,
}), }),
}, },
gateway: { gateway: {
startAccount: async (ctx) => { startAccount: async (ctx) => {
const account = ctx.account; const { accountId, inboxId } = ctx.account;
ctx.setStatus({ ctx.setStatus({ accountId, emailAddress: inboxId });
accountId: account.accountId,
emailAddress: account.inboxId,
});
ctx.log?.info( ctx.log?.info(
`[${account.accountId}] starting AgentMail provider (email: ${ `[${accountId}] starting AgentMail provider (email: ${
account.inboxId ?? "unknown" inboxId ?? "unknown"
})` })`
); );
// Lazy import: avoid ESM init cycles
const { monitorAgentMailProvider } = await import("./monitor.js"); const { monitorAgentMailProvider } = await import("./monitor.js");
return monitorAgentMailProvider({ return monitorAgentMailProvider({
accountId: account.accountId, accountId,
abortSignal: ctx.abortSignal, abortSignal: ctx.abortSignal,
}); });
}, },

View File

@ -2,43 +2,24 @@ import type { AgentMailClient } from "agentmail";
import type { AgentMailConfig } from "./utils.js"; import type { AgentMailConfig } from "./utils.js";
/** /** Checks if a sender email matches any entry in a list (exact email or domain). */
* Checks if a sender email matches any entry in a list.
* Entries can be exact email addresses or domains.
*/
export function matchesList(senderEmail: string, list: string[]): boolean { export function matchesList(senderEmail: string, list: string[]): boolean {
if (!list || list.length === 0) return false; if (!list?.length) return false;
const sender = senderEmail.toLowerCase().trim();
const normalizedSender = senderEmail.toLowerCase().trim(); const domain = sender.split("@")[1];
const domain = normalizedSender.split("@")[1]; return list.some((e) => {
const entry = e.toLowerCase().trim();
return list.some((entry) => { return entry === sender || entry === domain;
const normalizedEntry = entry.toLowerCase().trim();
// Match exact email or domain
return normalizedEntry === normalizedSender || normalizedEntry === domain;
}); });
} }
/** /** Checks if sender is allowed: empty allowFrom = open mode (all allowed). */
* Determines if a sender is allowed based on allowFrom config.
*
* Logic:
* 1. If allowFrom is empty allowed (open mode)
* 2. If sender matches allowFrom allowed
* 3. Otherwise not allowed
*/
export function checkSenderAllowed( export function checkSenderAllowed(
senderEmail: string, senderEmail: string,
config: Pick<AgentMailConfig, "allowFrom">, config: Pick<AgentMailConfig, "allowFrom">
): boolean { ): boolean {
const { allowFrom = [] } = config; const { allowFrom = [] } = config;
return allowFrom.length === 0 || matchesList(senderEmail, allowFrom);
// Open mode: all senders allowed when allowFrom is empty
if (allowFrom.length === 0) {
return true;
}
return matchesList(senderEmail, allowFrom);
} }
/** /**
@ -47,7 +28,7 @@ export function checkSenderAllowed(
export async function labelMessageAllowed( export async function labelMessageAllowed(
client: AgentMailClient, client: AgentMailClient,
inboxId: string, inboxId: string,
messageId: string, messageId: string
): Promise<void> { ): Promise<void> {
await client.inboxes.messages.update(inboxId, messageId, { await client.inboxes.messages.update(inboxId, messageId, {
addLabels: ["allowed"], addLabels: ["allowed"],

View File

@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { registerPluginHttpRoute } from "clawdbot/plugin-sdk"; import { registerPluginHttpRoute } from "clawdbot/plugin-sdk";
import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js"; import { resolveCredentials } from "./accounts.js";
import { getAgentMailClient } from "./client.js"; import { getAgentMailClient } from "./client.js";
import { getAgentMailRuntime } from "./runtime.js"; import { getAgentMailRuntime } from "./runtime.js";
import { checkSenderAllowed, labelMessageAllowed } from "./filtering.js"; import { checkSenderAllowed, labelMessageAllowed } from "./filtering.js";
@ -16,30 +16,26 @@ export type MonitorAgentMailOptions = {
}; };
// Runtime state tracking // Runtime state tracking
const runtimeState = new Map< type RuntimeState = {
string, running: boolean;
{ lastStartAt: number | null;
running: boolean; lastStopAt: number | null;
lastStartAt: number | null; lastError: string | null;
lastStopAt: number | null; lastInboundAt?: number | null;
lastError: string | null; lastOutboundAt?: number | null;
lastInboundAt?: number | null; };
lastOutboundAt?: number | null; const runtimeState = new Map<string, RuntimeState>();
} const defaultState: RuntimeState = {
>(); running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
};
function recordState( function recordState(accountId: string, state: Partial<RuntimeState>) {
accountId: string,
state: Partial<typeof runtimeState extends Map<string, infer V> ? V : never>
) {
const key = `agentmail:${accountId}`; const key = `agentmail:${accountId}`;
runtimeState.set(key, { runtimeState.set(key, {
...(runtimeState.get(key) ?? { ...(runtimeState.get(key) ?? defaultState),
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
}),
...state, ...state,
}); });
} }
@ -87,27 +83,13 @@ export async function monitorAgentMailProvider(
module: "agentmail-auto-reply", module: "agentmail-auto-reply",
}); });
const logVerbose = (msg: string) => { const logVerbose = (msg: string) => {
if (core.logging.shouldLogVerbose()) { if (core.logging.shouldLogVerbose()) (logger.debug ?? logger.info)(msg);
if (logger.debug) {
logger.debug(msg);
} else {
logger.info(msg);
}
}
}; };
const accountId = opts.accountId ?? "default"; const accountId = opts.accountId ?? "default";
const account = resolveAgentMailAccount({ cfg, accountId });
if (!account.configured) {
logger.warn("AgentMail not configured (missing token or email address)");
return;
}
const { apiKey, inboxId, webhookPath } = resolveCredentials(cfg); const { apiKey, inboxId, webhookPath } = resolveCredentials(cfg);
if (!apiKey || !inboxId) { if (!apiKey || !inboxId) {
logger.warn("AgentMail token or email address not found"); logger.warn("AgentMail not configured (missing token or email address)");
return; return;
} }

View File

@ -6,9 +6,10 @@ import type {
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import { import {
DEFAULT_WEBHOOK_PATH,
extractPathFromUrl,
resolveAgentMailAccount, resolveAgentMailAccount,
resolveCredentials, resolveCredentials,
resolveDefaultAgentMailAccountId,
} from "./accounts.js"; } from "./accounts.js";
import type { AgentMailConfig, CoreConfig } from "./utils.js"; import type { AgentMailConfig, CoreConfig } from "./utils.js";
@ -86,12 +87,9 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
}, },
configure: async ({ cfg, prompter, accountOverrides }) => { configure: async ({ cfg, prompter, accountOverrides }) => {
const defaultAccountId = resolveDefaultAgentMailAccountId(
cfg as CoreConfig
);
const accountId = accountOverrides.agentmail const accountId = accountOverrides.agentmail
? normalizeAccountId(accountOverrides.agentmail) ? normalizeAccountId(accountOverrides.agentmail)
: defaultAccountId; : DEFAULT_ACCOUNT_ID;
let next = cfg as MoltbotConfig; let next = cfg as MoltbotConfig;
const account = resolveAgentMailAccount({ const account = resolveAgentMailAccount({
@ -160,9 +158,6 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
// Apply config // Apply config
next = updateAgentMailConfig(next, { enabled: true, token, emailAddress }); next = updateAgentMailConfig(next, { enabled: true, token, emailAddress });
// Webhook configuration
const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail";
// Ask if gateway has a public URL // Ask if gateway has a public URL
const hasPublicUrl = await prompter.confirm({ const hasPublicUrl = await prompter.confirm({
message: message:
@ -174,7 +169,6 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
let webhookPath = DEFAULT_WEBHOOK_PATH; let webhookPath = DEFAULT_WEBHOOK_PATH;
if (hasPublicUrl) { if (hasPublicUrl) {
// Get the full public webhook URL
webhookUrl = String( webhookUrl = String(
await prompter.text({ await prompter.text({
message: "Full webhook URL", message: "Full webhook URL",
@ -185,21 +179,13 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
if ( if (
!trimmed.startsWith("http://") && !trimmed.startsWith("http://") &&
!trimmed.startsWith("https://") !trimmed.startsWith("https://")
) { )
return "Must start with http:// or https://"; return "Must start with http:// or https://";
}
return undefined; return undefined;
}, },
}) })
).trim(); ).trim();
webhookPath = extractPathFromUrl(webhookUrl) ?? DEFAULT_WEBHOOK_PATH;
// Derive path from URL
try {
const parsed = new URL(webhookUrl);
webhookPath = parsed.pathname || DEFAULT_WEBHOOK_PATH;
} catch {
webhookPath = DEFAULT_WEBHOOK_PATH;
}
// Auto-register webhook with AgentMail // Auto-register webhook with AgentMail
try { try {
@ -349,22 +335,21 @@ async function promptForNewInbox(
await prompter.note(`Your new inbox: ${emailAddress}`, "Inbox Created"); await prompter.note(`Your new inbox: ${emailAddress}`, "Inbox Created");
return emailAddress; return emailAddress;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
const isConflict = const lower = msg.toLowerCase();
message.toLowerCase().includes("already") || if (
message.toLowerCase().includes("taken") || lower.includes("already") ||
message.toLowerCase().includes("exists") || lower.includes("taken") ||
message.includes("409"); lower.includes("exists") ||
msg.includes("409")
if (isConflict) { ) {
await prompter.note( await prompter.note(
`${targetEmail} is already taken. Please try a different address.`, `${targetEmail} is already taken. Please try a different address.`,
"Address Unavailable" "Address Unavailable"
); );
continue; continue;
} }
throw new Error(`Failed to create inbox: ${msg}`);
throw new Error(`Failed to create inbox: ${message}`);
} }
} }
} }

View File

@ -16,62 +16,39 @@ export function extractMessageBody(
return msg.extractedText ?? msg.extractedHtml ?? msg.text ?? msg.html ?? ""; return msg.extractedText ?? msg.extractedHtml ?? msg.text ?? msg.html ?? "";
} }
/**
* Formats thread-level metadata.
*/
function formatThreadHeader(thread: Thread): string { function formatThreadHeader(thread: Thread): string {
const lines: string[] = []; return [
thread.subject && `Subject: ${thread.subject}`,
if (thread.subject) { `Senders: ${thread.senders.join(", ")}`,
lines.push(`Subject: ${thread.subject}`); `Recipients: ${thread.recipients.join(", ")}`,
} `Messages: ${thread.messageCount}`,
]
lines.push(`Senders: ${thread.senders.join(", ")}`); .filter(Boolean)
lines.push(`Recipients: ${thread.recipients.join(", ")}`); .join("\n");
lines.push(`Messages: ${thread.messageCount}`);
return lines.join("\n");
} }
/**
* Formats recipients for a message (to, cc, bcc).
*/
function formatMessageRecipients(msg: Message): string { function formatMessageRecipients(msg: Message): string {
const parts: string[] = [`To: ${msg.to.join(", ")}`]; return [
`To: ${msg.to.join(", ")}`,
if (msg.cc?.length) { msg.cc?.length && `Cc: ${msg.cc.join(", ")}`,
parts.push(`Cc: ${msg.cc.join(", ")}`); msg.bcc?.length && `Bcc: ${msg.bcc.join(", ")}`,
} ]
.filter(Boolean)
if (msg.bcc?.length) { .join("\n");
parts.push(`Bcc: ${msg.bcc.join(", ")}`);
}
return parts.join("\n");
} }
/**
* Formats a single message for context display.
*/
function formatMessage(msg: Message): string { function formatMessage(msg: Message): string {
const recipients = formatMessageRecipients(msg);
const attachments = formatAttachments(msg.attachments); const attachments = formatAttachments(msg.attachments);
const body = extractMessageBody(msg); return [
const timestamp = formatUtcDate(msg.createdAt); `--- ${formatUtcDate(msg.createdAt)} ---`,
const parts = [
`--- ${timestamp} ---`,
`From: ${msg.from}`, `From: ${msg.from}`,
recipients, formatMessageRecipients(msg),
]; attachments,
"",
if (attachments) { extractMessageBody(msg),
parts.push(attachments); ]
} .filter(Boolean)
.join("\n");
parts.push("", body);
return parts.join("\n");
} }
/** /**