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 {
listAgentMailAccountIds,
resolveAgentMailAccount,
resolveCredentials,
resolveDefaultAgentMailAccountId,
} from "./accounts.js";
import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js";
import type { CoreConfig } from "./utils.js";
describe("resolveCredentials", () => {
@ -99,7 +94,9 @@ describe("resolveCredentials", () => {
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");
expect(result.webhookUrl).toBe(
"https://env-gateway.example.com/hooks/email"
);
});
it("derives webhookPath from webhookUrl", () => {
@ -218,19 +215,3 @@ describe("resolveAgentMailAccount", () => {
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,
} 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. */
export type ResolvedAgentMailCredentials = {
apiKey?: string;
@ -30,10 +14,10 @@ export type ResolvedAgentMailCredentials = {
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 "/". */
function extractPathFromUrl(url: string): string | undefined {
export function extractPathFromUrl(url: string): string | undefined {
try {
const parsed = new URL(url);
// Return undefined if just root path "/" - use default instead

View File

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

View File

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

View File

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

View File

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

View File

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