Initilal commit

This commit is contained in:
Haakam Aujla 2026-01-26 17:41:17 -08:00
parent 87f3a59e3d
commit a83725da19
29 changed files with 2639 additions and 163 deletions

5
.github/labeler.yml vendored
View File

@ -1,3 +1,8 @@
"channel: agentmail":
- changed-files:
- any-glob-to-any-file:
- "extensions/agentmail/**"
- "docs/channels/agentmail.md"
"channel: bluebubbles":
- changed-files:
- any-glob-to-any-file:

192
docs/channels/agentmail.md Normal file
View File

@ -0,0 +1,192 @@
---
summary: "AgentMail email channel support, capabilities, and configuration"
read_when:
- Working on AgentMail/email channel features
---
# AgentMail
AgentMail is an email API service designed for AI agents. Clawdbot connects to AgentMail via
webhooks to receive incoming emails and uses the AgentMail API to send replies. This enables
email as a conversation channel for your AI assistant.
Status: supported via plugin. Direct messages (email threads), media (attachments as links),
and threading are supported.
## Plugin required
AgentMail ships as a plugin and is not bundled with the core install.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/agentmail
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/agentmail
```
Details: [Plugins](/plugin)
## Setup
1. Install the AgentMail plugin:
- From npm: `clawdbot plugins install @clawdbot/agentmail`
- From a local checkout: `clawdbot plugins install ./extensions/agentmail`
2. Create an AgentMail account at [agentmail.to](https://agentmail.to)
3. Get your API key from the AgentMail dashboard
4. Create an inbox (or use an existing one) and note the inbox ID
5. Webhook setup:
- **Automatic**: During onboarding, provide your gateway's public URL and the webhook will be auto-registered
- **Manual**: Register in the AgentMail dashboard with URL `https://your-gateway/webhooks/agentmail` and event type `message.received`
6. Configure credentials:
- Env: `AGENTMAIL_TOKEN`, `AGENTMAIL_EMAIL_ADDRESS`
- Or config: `channels.agentmail.token`, `channels.agentmail.emailAddress`
- If both are set, config takes precedence.
7. Restart the gateway (or finish onboarding)
8. Send an email to your AgentMail inbox to test the integration
Minimal config:
```json5
{
channels: {
agentmail: {
enabled: true,
token: "am_***",
emailAddress: "you@agentmail.to",
},
},
}
```
## Configuration
| Key | Type | Description |
| -------------- | -------- | -------------------------------------------------------- |
| `name` | string | Account name for identifying this configuration |
| `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`) |
| `allowlist` | string[] | Allowed sender emails/domains |
| `blocklist` | string[] | Blocked sender emails/domains |
## Sender Filtering
AgentMail supports allowlist and blocklist filtering for incoming emails. Both lists accept
email addresses and domains.
### Allowlist/Blocklist Logic
1. If sender is on the blocklist, the message is labeled `blocked` and ignored
2. If sender is on the allowlist, the message is labeled `allowed` and triggers Clawdbot
3. If allowlist is empty and sender is not blocked, the message is `allowed` (open mode)
4. If allowlist is non-empty and sender is not on it, the message is ignored
### Example Configuration
```json5
{
channels: {
agentmail: {
enabled: true,
token: "am_***",
emailAddress: "clawd@agentmail.to",
// Allow specific emails and domains
allowlist: ["alice@example.com", "trusted-domain.org"],
// Block specific emails and domains
blocklist: ["spam@bad.com", "bad-domain.net"],
},
},
}
```
### Domain Matching
Domain entries match any email from that domain:
- `example.org` in allowlist allows `alice@example.org`, `bob@example.org`, etc.
- `bad.com` in blocklist blocks `spam@bad.com`, `anything@bad.com`, etc.
## Thread Context
When an email arrives, Clawdbot fetches the full email thread to provide conversation context
to the AI. This enables the assistant to understand prior messages in the thread and provide
contextually relevant replies.
Thread context is automatically included when:
- The incoming email is part of an existing thread
- The thread has more than one message
The plugin uses AgentMail's `extracted_text` field which contains only the new content from
each message (excluding quoted reply text). This provides cleaner context without duplicated
quoted sections.
## Environment Variables
| Variable | Description |
| -------------------------- | ----------------------------- |
| `AGENTMAIL_TOKEN` | AgentMail API token |
| `AGENTMAIL_EMAIL_ADDRESS` | AgentMail inbox email address |
| `AGENTMAIL_WEBHOOK_PATH` | Custom webhook path |
## Webhook Security
AgentMail webhooks should be configured with HTTPS endpoints. Ensure your gateway is
accessible from the internet and properly secured.
For local development, you can use tools like ngrok to tunnel webhooks to your local machine:
```bash
ngrok http 18789
```
Then register the ngrok URL as your webhook endpoint in the AgentMail dashboard.
## Capabilities
| Feature | Supported |
| ------------------- | -------------------- |
| Direct messages | Yes |
| Groups/rooms | No |
| Threads | Yes |
| Media (attachments) | Partial (links only) |
| Reactions | No |
| Polls | No |
## Troubleshooting
### Messages not being received
1. Verify the webhook is registered in AgentMail dashboard
2. Check that the webhook URL is correct and accessible
3. Ensure `token` and `emailAddress` are configured correctly
4. Check the gateway logs for webhook errors
### Replies not being sent
1. Verify the API token has send permissions
2. Check the gateway logs for outbound errors
3. Ensure the email address is correct
### Sender not allowed
1. Check the `allowlist` and `blocklist` configuration
2. Verify the sender email matches the expected format
3. Check if the sender's domain is blocked

View File

@ -0,0 +1,22 @@
# Changelog
## 2026.1.26
### Features
- Initial AgentMail plugin release
- Email channel integration via AgentMail API
- Webhook-based inbound message handling
- Full thread context fetching for conversation history
- Sender allowlist and blocklist filtering with automatic labeling
- Attachment metadata in thread context
- Agent tool for fetching attachment download URLs on demand
- Interactive onboarding flow with inbox creation support
- Environment variable fallback for credentials (AGENTMAIL_TOKEN, AGENTMAIL_EMAIL_ADDRESS)
### Technical
- Uses AgentMail SDK v0.2.4
- Extracted text/HTML preference for cleaner message bodies
- Zod-based configuration schema
- Comprehensive test coverage for pure functions

View File

@ -0,0 +1,59 @@
# @clawdbot/agentmail
Email channel plugin for Clawdbot via [AgentMail](https://agentmail.to).
## Installation
From npm:
```bash
clawdbot plugins install @clawdbot/agentmail
```
From local checkout:
```bash
clawdbot plugins install ./extensions/agentmail
```
## Configuration
Set credentials via environment variables:
```bash
export AGENTMAIL_TOKEN="am_..."
export AGENTMAIL_EMAIL_ADDRESS="you@agentmail.to"
```
Or via config:
```json5
{
channels: {
agentmail: {
enabled: true,
token: "am_...",
emailAddress: "you@agentmail.to",
},
},
}
```
## 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
- Full thread context for conversation history
- Sender allowlist/blocklist filtering
- Attachment metadata with on-demand download URLs
- Interactive onboarding with inbox creation
## Documentation
See [AgentMail channel docs](https://docs.clawd.bot/channels/agentmail) for full details.

View File

@ -0,0 +1,11 @@
{
"id": "agentmail",
"channels": [
"agentmail"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { agentmailPlugin } from "./src/channel.js";
import { setAgentMailRuntime } from "./src/runtime.js";
const plugin = {
id: "agentmail",
name: "AgentMail",
description: "Email channel plugin via AgentMail API",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setAgentMailRuntime(api.runtime);
api.registerChannel({ plugin: agentmailPlugin });
},
};
export default plugin;

View File

@ -0,0 +1,33 @@
{
"name": "@clawdbot/agentmail",
"version": "2026.1.26",
"type": "module",
"description": "Clawdbot AgentMail email channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "agentmail",
"label": "AgentMail (Email Inbox API)",
"selectionLabel": "AgentMail (Email Inbox API)",
"docsPath": "/channels/agentmail",
"docsLabel": "agentmail",
"blurb": "Email channel via AgentMail; configure API key and inbox.",
"order": 80,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@clawdbot/agentmail",
"localPath": "extensions/agentmail",
"defaultChoice": "npm"
}
},
"dependencies": {
"agentmail": "^0.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"clawdbot": "workspace:*"
}
}

View File

@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import {
listAgentMailAccountIds,
resolveAgentMailAccount,
resolveCredentials,
resolveDefaultAgentMailAccountId,
} from "./accounts.js";
import type { CoreConfig } from "./utils.js";
describe("resolveCredentials", () => {
it("resolves from config", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {
token: "am_config_token",
emailAddress: "inbox@agentmail.to",
},
},
};
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", () => {
const cfg: CoreConfig = {};
const env = {
AGENTMAIL_TOKEN: "am_env_token",
AGENTMAIL_EMAIL_ADDRESS: "env@agentmail.to",
};
const result = resolveCredentials(cfg, env);
expect(result.apiKey).toBe("am_env_token");
expect(result.inboxId).toBe("env@agentmail.to");
});
it("config takes precedence over env", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {
token: "am_config_token",
emailAddress: "config@agentmail.to",
},
},
};
const env = {
AGENTMAIL_TOKEN: "am_env_token",
AGENTMAIL_EMAIL_ADDRESS: "env@agentmail.to",
};
const result = resolveCredentials(cfg, env);
expect(result.apiKey).toBe("am_config_token");
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",
};
const result = resolveCredentials(cfg, env);
expect(result.webhookUrl).toBe("https://env-gateway.example.com");
});
});
describe("resolveAgentMailAccount", () => {
it("resolves configured account", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {
name: "My Email",
enabled: true,
token: "am_token",
emailAddress: "inbox@agentmail.to",
},
},
};
const result = resolveAgentMailAccount({ cfg });
expect(result.accountId).toBe("default");
expect(result.name).toBe("My Email");
expect(result.enabled).toBe(true);
expect(result.configured).toBe(true);
expect(result.inboxId).toBe("inbox@agentmail.to");
});
it("returns configured=false when missing token", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {
emailAddress: "inbox@agentmail.to",
},
},
};
const result = resolveAgentMailAccount({ cfg });
expect(result.configured).toBe(false);
});
it("returns configured=false when missing emailAddress", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {
token: "am_token",
},
},
};
const result = resolveAgentMailAccount({ cfg });
expect(result.configured).toBe(false);
});
it("defaults enabled to true", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {},
},
};
const result = resolveAgentMailAccount({ cfg });
expect(result.enabled).toBe(true);
});
it("respects enabled=false", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {
enabled: false,
},
},
};
const result = resolveAgentMailAccount({ cfg });
expect(result.enabled).toBe(false);
});
it("trims name whitespace", () => {
const cfg: CoreConfig = {
channels: {
agentmail: {
name: " Trimmed Name ",
},
},
};
const result = resolveAgentMailAccount({ cfg });
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

@ -0,0 +1,66 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { AgentMailConfig, CoreConfig, 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.
*/
export function resolveDefaultAgentMailAccountId(cfg: CoreConfig): string {
const ids = listAgentMailAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
/** Resolved AgentMail credentials and paths. */
export type ResolvedAgentMailCredentials = {
apiKey?: string;
inboxId?: string;
webhookUrl?: string;
webhookPath: string;
};
/**
* Resolves AgentMail credentials from config and environment.
* Maps user-facing keys (token, emailAddress) to SDK names (apiKey, inboxId).
*/
export function resolveCredentials(
cfg: CoreConfig,
env: Record<string, string | undefined> = process.env,
): ResolvedAgentMailCredentials {
const base = cfg.channels?.agentmail ?? {};
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",
};
}
/**
* Resolves a specific AgentMail account with its configuration and status.
*/
export function resolveAgentMailAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedAgentMailAccount {
const accountId = normalizeAccountId(params.accountId);
const base = (params.cfg.channels?.agentmail ?? {}) as AgentMailConfig;
const { apiKey, inboxId } = resolveCredentials(params.cfg);
return {
accountId,
name: base.name?.trim() || undefined,
enabled: base.enabled !== false,
configured: Boolean(apiKey && inboxId),
config: base,
inboxId,
};
}

View File

@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
formatAttachment,
formatAttachments,
formatAttachmentResponse,
formatFileSize,
} from "./attachment.js";
describe("formatFileSize", () => {
it("formats bytes", () => {
expect(formatFileSize(0)).toBe("0B");
expect(formatFileSize(512)).toBe("512B");
expect(formatFileSize(1023)).toBe("1023B");
});
it("formats kilobytes", () => {
expect(formatFileSize(1024)).toBe("1.0KB");
expect(formatFileSize(1536)).toBe("1.5KB");
expect(formatFileSize(10240)).toBe("10.0KB");
});
it("formats megabytes", () => {
expect(formatFileSize(1024 * 1024)).toBe("1.0MB");
expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1.5MB");
expect(formatFileSize(25 * 1024 * 1024)).toBe("25.0MB");
});
});
describe("formatAttachment", () => {
it("formats attachment with all fields", () => {
const result = formatAttachment({
attachmentId: "att_123",
filename: "document.pdf",
contentType: "application/pdf",
size: 2048,
});
expect(result).toBe(" - document.pdf (application/pdf, 2.0KB, id: att_123)");
});
it("handles missing filename", () => {
const result = formatAttachment({
attachmentId: "att_456",
contentType: "image/png",
size: 512,
});
expect(result).toBe(" - unnamed (image/png, 512B, id: att_456)");
});
it("handles missing content type", () => {
const result = formatAttachment({
attachmentId: "att_789",
filename: "file.bin",
size: 1024,
});
expect(result).toBe(" - file.bin (unknown, 1.0KB, id: att_789)");
});
});
describe("formatAttachments", () => {
it("returns empty string for undefined", () => {
expect(formatAttachments(undefined)).toBe("");
});
it("returns empty string for empty array", () => {
expect(formatAttachments([])).toBe("");
});
it("formats single attachment", () => {
const result = formatAttachments([
{ attachmentId: "att_1", filename: "a.txt", contentType: "text/plain", size: 100 },
]);
expect(result).toBe("Attachments:\n - a.txt (text/plain, 100B, id: att_1)");
});
it("formats multiple attachments", () => {
const result = formatAttachments([
{ attachmentId: "att_1", filename: "a.txt", contentType: "text/plain", size: 100 },
{ attachmentId: "att_2", filename: "b.pdf", contentType: "application/pdf", size: 2048 },
]);
expect(result).toContain("Attachments:");
expect(result).toContain("a.txt");
expect(result).toContain("b.pdf");
});
});
describe("formatAttachmentResponse", () => {
it("formats full attachment response", () => {
const result = formatAttachmentResponse({
attachmentId: "att_123",
filename: "report.pdf",
contentType: "application/pdf",
size: 5120,
downloadUrl: "https://example.com/download/att_123",
expiresAt: new Date("2024-01-15T12:00:00Z"),
});
expect(result).toContain("Attachment: report.pdf");
expect(result).toContain("Type: application/pdf");
expect(result).toContain("Size: 5.0KB");
expect(result).toContain("Download URL: https://example.com/download/att_123");
expect(result).toContain("Expires:");
expect(result).toContain("UTC");
});
it("handles missing optional fields", () => {
const result = formatAttachmentResponse({
attachmentId: "att_456",
size: 100,
downloadUrl: "https://example.com/download/att_456",
expiresAt: new Date("2024-06-20T15:00:00Z"),
});
expect(result).toContain("Attachment: unnamed");
expect(result).toContain("Type: unknown");
});
});

View File

@ -0,0 +1,44 @@
import type { AgentMail } from "agentmail";
import { formatUtcDate } from "./utils.js";
type Attachment = AgentMail.Attachment;
type AttachmentResponse = AgentMail.AttachmentResponse;
/** Formats file size in human-readable format. */
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
/**
* Formats a single attachment metadata for display.
*/
export function formatAttachment(att: Attachment): string {
const name = att.filename ?? "unnamed";
const type = att.contentType ?? "unknown";
const size = formatFileSize(att.size);
return ` - ${name} (${type}, ${size}, id: ${att.attachmentId})`;
}
/**
* Formats attachments list for a message.
*/
export function formatAttachments(attachments: Attachment[] | undefined): string {
if (!attachments?.length) return "";
return `Attachments:\n${attachments.map(formatAttachment).join("\n")}`;
}
/**
* Formats an attachment response (with download URL) for display.
*/
export function formatAttachmentResponse(att: AttachmentResponse): string {
return [
`Attachment: ${att.filename ?? "unnamed"}`,
`Type: ${att.contentType ?? "unknown"}`,
`Size: ${formatFileSize(att.size)}`,
`Download URL: ${att.downloadUrl}`,
`Expires: ${formatUtcDate(att.expiresAt)}`,
].join("\n");
}

View File

@ -0,0 +1,276 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
normalizeAccountId,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "clawdbot/plugin-sdk";
import {
listAgentMailAccountIds,
resolveAgentMailAccount,
resolveDefaultAgentMailAccountId,
resolveCredentials,
} from "./accounts.js";
import { getAgentMailClient } from "./client.js";
import { AgentMailConfigSchema } from "./config-schema.js";
import { agentmailOnboardingAdapter } from "./onboarding.js";
import { agentmailOutbound } from "./outbound.js";
import { createAgentMailTools } from "./tools.js";
import type { CoreConfig, ResolvedAgentMailAccount } from "./utils.js";
const meta = {
id: "agentmail",
label: "AgentMail (Email Inbox API)",
selectionLabel: "AgentMail (Email Inbox API)",
docsPath: "/channels/agentmail",
docsLabel: "agentmail",
blurb: "Email channel via AgentMail; configure token and email address.",
order: 80,
quickstartAllowFrom: true,
};
export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
id: "agentmail",
meta,
capabilities: {
chatTypes: ["direct"],
media: true,
threads: true,
polls: false,
reactions: false,
},
reload: { configPrefixes: ["channels.agentmail"] },
configSchema: buildChannelConfigSchema(AgentMailConfigSchema),
config: {
listAccountIds: (cfg) => listAgentMailAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveAgentMailAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) =>
resolveDefaultAgentMailAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "agentmail",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "agentmail",
accountId,
clearBaseFields: [
"name",
"token",
"emailAddress",
"webhookPath",
"allowlist",
"blocklist",
],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
emailAddress: account.inboxId,
}),
resolveAllowFrom: ({ cfg }) =>
((cfg as CoreConfig).channels?.agentmail?.allowlist ?? []).map((entry) =>
String(entry)
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).toLowerCase().trim())
.filter(Boolean),
},
security: {
resolveDmPolicy: ({ account }) => ({
policy: "open", // AgentMail uses allowlist/blocklist instead of pairing
allowFrom: account.config.allowlist ?? [],
policyPath: "channels.agentmail.allowlist",
allowFromPath: "channels.agentmail.allowlist",
approveHint:
"Add email addresses or domains to channels.agentmail.allowlist",
normalizeEntry: (raw) => raw.toLowerCase().trim(),
}),
collectWarnings: ({ account }) => {
const warnings: string[] = [];
const { allowlist = [], blocklist = [] } = account.config;
if (allowlist.length === 0 && blocklist.length === 0) {
warnings.push(
"- AgentMail: No allowlist or blocklist configured. All senders will be allowed."
);
}
return warnings;
},
},
messaging: {
normalizeTarget: (raw) => raw.trim() || undefined,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
// Check if it looks like an email
return trimmed.includes("@") && trimmed.includes(".");
},
hint: "<email@example.com>",
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "agentmail",
accountId,
name,
}),
validateInput: ({ input }) => {
if (input.useEnv) return null;
if (!input.token?.trim()) return "AgentMail requires --token";
if (!input.emailAddress?.trim()) return "AgentMail requires --email-address";
return null;
},
applyAccountConfig: ({ cfg, input }) => {
const existing = (cfg as CoreConfig).channels?.agentmail ?? {};
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() } : {}),
}),
},
},
};
},
},
outbound: agentmailOutbound,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
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,
}),
probeAccount: async ({ cfg }) => {
try {
const { apiKey, inboxId } = resolveCredentials(cfg as CoreConfig);
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;
return {
ok: true,
elapsedMs,
meta: {
inboxId: inbox.inboxId, // inboxId is the email address
},
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
elapsedMs: 0,
};
}
},
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,
probe,
lastProbeAt: runtime?.lastProbeAt ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
emailAddress: account.inboxId,
});
ctx.log?.info(
`[${account.accountId}] starting AgentMail provider (email: ${
account.inboxId ?? "unknown"
})`
);
// Lazy import: avoid ESM init cycles
const { monitorAgentMailProvider } = await import("./monitor.js");
return monitorAgentMailProvider({
accountId: account.accountId,
abortSignal: ctx.abortSignal,
});
},
},
onboarding: agentmailOnboardingAdapter,
agentTools: () => createAgentMailTools(),
};

View File

@ -0,0 +1,31 @@
import { AgentMailClient } from "agentmail";
import { resolveCredentials } from "./accounts.js";
import { getAgentMailRuntime } from "./runtime.js";
import type { CoreConfig } from "./utils.js";
let sharedClient: AgentMailClient | null = null;
/** Creates or returns a shared AgentMailClient instance. */
export function getAgentMailClient(apiKey?: string): AgentMailClient {
if (sharedClient) return sharedClient;
const key = apiKey ?? getResolvedCredentials().apiKey;
if (!key) throw new Error("AgentMail token is required");
sharedClient = new AgentMailClient({ apiKey: key });
return sharedClient;
}
/** Resolves credentials from current config. */
export function getResolvedCredentials() {
return resolveCredentials(getAgentMailRuntime().config.loadConfig() as CoreConfig);
}
/** Returns client and inboxId, or throws if not configured. */
export function getClientAndInbox(): { client: AgentMailClient; inboxId: string } {
const { apiKey, inboxId } = getResolvedCredentials();
if (!apiKey || !inboxId) throw new Error("AgentMail not configured (missing token or email address)");
return { client: getAgentMailClient(apiKey), inboxId };
}

View File

@ -0,0 +1,24 @@
import { z } from "zod";
/**
* Zod schema for AgentMail channel configuration.
* Validates user-provided config at runtime.
*/
export const AgentMailConfigSchema = z.object({
/** Account name for identifying this AgentMail configuration. */
name: z.string().optional(),
/** If false, do not start AgentMail channel. Default: true. */
enabled: z.boolean().optional(),
/** AgentMail API token (required). */
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). */
webhookUrl: z.string().optional(),
/** Custom webhook path for receiving emails (default: /webhooks/agentmail). */
webhookPath: z.string().optional(),
/** Allowlist of email addresses and/or domains. */
allowlist: z.array(z.string()).optional(),
/** Blocklist of email addresses and/or domains. */
blocklist: z.array(z.string()).optional(),
});

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { checkSenderFilter, matchesList } from "./filtering.js";
describe("matchesList", () => {
it("returns false for empty list", () => {
expect(matchesList("user@example.com", [])).toBe(false);
});
it("matches exact email", () => {
expect(matchesList("user@example.com", ["user@example.com"])).toBe(true);
});
it("matches case-insensitively", () => {
expect(matchesList("User@Example.COM", ["user@example.com"])).toBe(true);
});
it("matches domain", () => {
expect(matchesList("anyone@example.com", ["example.com"])).toBe(true);
});
it("does not match different domain", () => {
expect(matchesList("user@other.com", ["example.com"])).toBe(false);
});
it("does not match partial email", () => {
expect(matchesList("user@example.com", ["other@example.com"])).toBe(false);
});
it("matches subdomain via domain suffix", () => {
expect(matchesList("user@sub.example.com", ["sub.example.com"])).toBe(true);
});
});
describe("checkSenderFilter", () => {
it("blocks sender on blocklist", () => {
const result = checkSenderFilter("spam@bad.com", {
blocklist: ["bad.com"],
allowlist: [],
});
expect(result).toEqual({ allowed: false, blocked: true, label: "blocked" });
});
it("allows sender on allowlist", () => {
const result = checkSenderFilter("friend@good.com", {
blocklist: [],
allowlist: ["good.com"],
});
expect(result).toEqual({ allowed: true, blocked: false, label: "allowed" });
});
it("allows all non-blocked in open mode (empty allowlist)", () => {
const result = checkSenderFilter("anyone@anywhere.com", {
blocklist: [],
allowlist: [],
});
expect(result).toEqual({ allowed: true, blocked: false, label: "allowed" });
});
it("rejects sender not on non-empty allowlist", () => {
const result = checkSenderFilter("stranger@unknown.com", {
blocklist: [],
allowlist: ["trusted.com"],
});
expect(result).toEqual({ allowed: false, blocked: false, label: null });
});
it("blocklist takes precedence over allowlist", () => {
const result = checkSenderFilter("user@both.com", {
blocklist: ["both.com"],
allowlist: ["both.com"],
});
expect(result).toEqual({ allowed: false, blocked: true, label: "blocked" });
});
});

View File

@ -0,0 +1,91 @@
import type { AgentMailClient } from "agentmail";
import type { AgentMailConfig, FilterResult } from "./utils.js";
/**
* 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 {
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();
return (
normalizedEntry === normalizedSender || // exact email match
normalizedEntry === domain || // exact domain match
normalizedSender.endsWith(`@${normalizedEntry}`) // domain suffix match
);
});
}
/**
* Determines the filter result for a sender based on allowlist/blocklist.
*
* Logic:
* 1. If sender is on blocklist blocked
* 2. If sender is on allowlist allowed
* 3. If allowlist is empty and not blocked allowed (open mode)
* 4. If allowlist is non-empty and sender not on it not allowed
*/
export function checkSenderFilter(
senderEmail: string,
config: Pick<AgentMailConfig, "allowlist" | "blocklist">,
): FilterResult {
const { allowlist = [], blocklist = [] } = config;
// Check blocklist first
if (matchesList(senderEmail, blocklist)) {
return { allowed: false, blocked: true, label: "blocked" };
}
// Check allowlist
if (allowlist.length === 0) {
// Open mode: all non-blocked senders are allowed
return { allowed: true, blocked: false, label: "allowed" };
}
if (matchesList(senderEmail, allowlist)) {
return { allowed: true, blocked: false, label: "allowed" };
}
// Not on allowlist and allowlist is non-empty
return { allowed: false, blocked: false, label: null };
}
/**
* Labels a message via the AgentMail API.
*/
export async function labelMessage(
client: AgentMailClient,
inboxId: string,
messageId: string,
label: "allowed" | "blocked",
): Promise<void> {
await client.inboxes.messages.update(inboxId, messageId, {
addLabels: [label],
});
}
/**
* Processes an incoming message and applies filtering.
* Returns the filter result and labels the message if needed.
*/
export async function filterAndLabelMessage(
client: AgentMailClient,
inboxId: string,
messageId: string,
senderEmail: string,
config: Pick<AgentMailConfig, "allowlist" | "blocklist">,
): Promise<FilterResult> {
const result = checkSenderFilter(senderEmail, config);
if (result.label) {
await labelMessage(client, inboxId, messageId, result.label);
}
return result;
}

View File

@ -0,0 +1,322 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { registerPluginHttpRoute } from "clawdbot/plugin-sdk";
import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js";
import { getAgentMailClient } from "./client.js";
import { getAgentMailRuntime } from "./runtime.js";
import { filterAndLabelMessage } from "./filtering.js";
import { extractMessageBody, fetchFormattedThread } from "./thread.js";
import { parseEmailFromAddress, parseNameFromAddress } from "./utils.js";
import type { CoreConfig, MessageReceivedEvent } from "./utils.js";
export type MonitorAgentMailOptions = {
accountId?: string | null;
abortSignal?: AbortSignal;
};
// 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;
}>();
function recordState(accountId: string, state: Partial<typeof runtimeState extends Map<string, infer V> ? V : never>) {
const key = `agentmail:${accountId}`;
runtimeState.set(key, { ...runtimeState.get(key) ?? { running: false, lastStartAt: null, lastStopAt: null, lastError: null }, ...state });
}
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 {
const subject = message.subject ? `Subject: ${message.subject}\n\n` : "";
return `${subject}${extractMessageBody(message)}`;
}
/**
* Main monitor function that sets up webhook handling for AgentMail.
*/
export async function monitorAgentMailProvider(
opts: MonitorAgentMailOptions = {}
): Promise<void> {
const core = getAgentMailRuntime();
const cfg = core.config.loadConfig() as CoreConfig;
const agentmailConfig = cfg.channels?.agentmail;
if (agentmailConfig?.enabled === false) {
return;
}
const logger = core.logging.getChildLogger({
module: "agentmail-auto-reply",
});
const logVerbose = (msg: string) => {
if (core.logging.shouldLogVerbose()) {
if (logger.debug) {
logger.debug(msg);
} else {
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");
return;
}
const client = getAgentMailClient(apiKey);
const allowlist = agentmailConfig?.allowlist ?? [];
const blocklist = agentmailConfig?.blocklist ?? [];
recordState(accountId, { running: true, lastStartAt: Date.now(), lastError: null });
logger.info(`AgentMail: starting monitor 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");
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 });
}
const event = payload as MessageReceivedEvent;
const message = event.message;
if (!message) {
return sendJson(res, 200, { ok: true, ignored: true });
}
// 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 filterResult = await filterAndLabelMessage(
client,
inboxId,
message.messageId,
senderEmail,
{ allowlist, blocklist }
);
if (!filterResult.allowed) {
logVerbose(
`agentmail: sender ${senderEmail} not allowed (blocked=${filterResult.blocked})`
);
return sendJson(res, 200, { ok: true, filtered: true });
}
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
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,
},
});
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 formattedBody = core.channel.reply.formatAgentEnvelope({
channel: "Email",
from: senderName,
timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: `${fullBody}\n[email message_id: ${message.messageId} thread: ${message.threadId}]`,
});
// Build inbound context
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: formattedBody,
RawBody: messageBody,
CommandBody: messageBody,
From: senderEmail,
To: inboxId,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: "direct" as const,
ConversationLabel: senderName,
SenderName: senderName,
SenderId: senderEmail,
SenderUsername: senderEmail.split("@")[0],
Provider: "agentmail" as const,
Surface: "agentmail" as const,
MessageSid: message.messageId,
MessageThreadId: message.threadId,
Timestamp: timestamp,
CommandAuthorized: true,
CommandSource: "text" as const,
OriginatingChannel: "agentmail" as const,
OriginatingTo: inboxId,
});
// Record session
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: {
sessionKey: route.mainSessionKey,
channel: "agentmail",
to: inboxId,
accountId: route.accountId,
},
onRecordError: (err) => {
logger.warn(`Failed updating session meta: ${String(err)}`);
},
});
const preview = messageBody.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(`agentmail inbound: from=${senderEmail} preview="${preview}"`);
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
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,
});
recordState(accountId, { lastOutboundAt: Date.now() });
},
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,
});
markDispatchIdle();
if (queuedFinal) {
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) });
}
};
// Register the webhook route
const unregisterHttp = registerPluginHttpRoute({
path: webhookPath,
pluginId: "agentmail",
accountId,
log: logVerbose,
handler: handleWebhook,
});
logger.info(`AgentMail: webhook registered at ${webhookPath}`);
// 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;
}
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}

View File

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { parseInboxInput, updateAgentMailConfig } from "./onboarding.js";
describe("parseInboxInput", () => {
it("parses username only to default domain", () => {
const result = parseInboxInput("myagent");
expect(result).toEqual({ username: "myagent", domain: "agentmail.to" });
});
it("parses full email address", () => {
const result = parseInboxInput("myagent@custom.com");
expect(result).toEqual({ username: "myagent", domain: "custom.com" });
});
it("lowercases input", () => {
const result = parseInboxInput("MyAgent@Custom.COM");
expect(result).toEqual({ username: "myagent", domain: "custom.com" });
});
it("trims whitespace", () => {
const result = parseInboxInput(" myagent ");
expect(result).toEqual({ username: "myagent", domain: "agentmail.to" });
});
it("trims whitespace from email", () => {
const result = parseInboxInput(" user@domain.com ");
expect(result).toEqual({ username: "user", domain: "domain.com" });
});
it("handles subdomain", () => {
const result = parseInboxInput("inbox@mail.example.org");
expect(result).toEqual({ username: "inbox", domain: "mail.example.org" });
});
});
describe("updateAgentMailConfig", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyConfig = any;
it("creates agentmail config from empty config", () => {
const result: AnyConfig = updateAgentMailConfig({} as never, { enabled: true });
expect(result.channels?.agentmail?.enabled).toBe(true);
});
it("preserves existing agentmail config", () => {
const cfg = {
channels: {
agentmail: { token: "existing-token", emailAddress: "test@example.com" },
},
} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, { enabled: true });
expect(result.channels?.agentmail?.token).toBe("existing-token");
expect(result.channels?.agentmail?.emailAddress).toBe("test@example.com");
expect(result.channels?.agentmail?.enabled).toBe(true);
});
it("overwrites existing values", () => {
const cfg = {
channels: {
agentmail: { token: "old-token" },
},
} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, { token: "new-token" });
expect(result.channels?.agentmail?.token).toBe("new-token");
});
it("preserves other channels", () => {
const cfg = {
channels: {
telegram: { token: "tg-token" },
agentmail: { token: "am-token" },
},
} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, { enabled: true });
expect(result.channels?.telegram?.token).toBe("tg-token");
expect(result.channels?.agentmail?.enabled).toBe(true);
});
it("adds allowlist", () => {
const cfg = {} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, {
allowlist: ["user@example.com", "example.org"],
});
expect(result.channels?.agentmail?.allowlist).toEqual([
"user@example.com",
"example.org",
]);
});
it("adds blocklist", () => {
const cfg = {} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, {
blocklist: ["spam@bad.com"],
});
expect(result.channels?.agentmail?.blocklist).toEqual(["spam@bad.com"]);
});
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");
});
});

View File

@ -0,0 +1,416 @@
import { AgentMailClient } from "agentmail";
import type {
ChannelOnboardingAdapter,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import {
resolveAgentMailAccount,
resolveCredentials,
resolveDefaultAgentMailAccountId,
} 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; // inboxId is the email address
}
/** 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: ClawdbotConfig,
updates: Partial<AgentMailConfig>
): ClawdbotConfig {
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const agentmail = (channels.agentmail ?? {}) as AgentMailConfig;
return {
...cfg,
channels: {
...channels,
agentmail: {
...agentmail,
...updates,
},
},
} as ClawdbotConfig;
}
export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const { apiKey, inboxId } = resolveCredentials(cfg as CoreConfig);
const configured = Boolean(apiKey && inboxId);
return {
channel,
configured,
statusLines: [
`AgentMail: ${configured ? `configured (${inboxId})` : "needs token"}`,
],
selectionHint: configured ? "configured" : "requires AgentMail account",
quickstartScore: configured ? 1 : 5,
};
},
configure: async ({ cfg, prompter, accountOverrides }) => {
const defaultAccountId = resolveDefaultAgentMailAccountId(
cfg as CoreConfig
);
const accountId = accountOverrides.agentmail
? normalizeAccountId(accountOverrides.agentmail)
: defaultAccountId;
let next = cfg as ClawdbotConfig;
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());
// 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 envToken = process.env.AGENTMAIL_TOKEN!.trim();
const client = new AgentMailClient({ apiKey: envToken });
const emailAddress = await selectOrCreateInbox(client, prompter);
return {
cfg: updateAgentMailConfig(next, { enabled: true, emailAddress }),
};
}
}
// If already configured, ask to keep
if (configured) {
const keep = await prompter.confirm({
message: `AgentMail already configured (${inboxId}). Keep current settings?`,
initialValue: true,
});
if (keep) {
return { cfg: next };
}
}
// Show help
await prompter.note(
[
"You'll need an AgentMail API token from https://agentmail.to",
"",
"We'll help you create an inbox after you enter your token.",
].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 });
// Webhook configuration
const DEFAULT_WEBHOOK_PATH = "/webhooks/agentmail";
// 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) {
// Get the public base URL
const baseUrl = String(
await prompter.text({
message: "Gateway public URL",
placeholder: "https://my-gateway.ngrok.io",
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().replace(/\/+$/, ""); // Remove trailing slashes
// 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}`;
}
}
webhookUrl = baseUrl;
// Auto-register webhook with AgentMail
try {
await client.webhooks.create({
url: `${baseUrl}${webhookPath}`,
eventTypes: ["message.received"],
clientId: `clawdbot-${emailAddress}`, // Idempotent per inbox
});
await prompter.note(
`Webhook registered: ${baseUrl}${webhookPath}`,
"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: ${baseUrl}${webhookPath}`,
" 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
next = updateAgentMailConfig(next, { webhookUrl, webhookPath });
// Ask about allowlist
const addAllowlist = await prompter.confirm({
message: "Add senders to allowlist? (Empty = allow all non-blocked)",
initialValue: false,
});
if (addAllowlist) {
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?.allowlist ?? [];
next = updateAgentMailConfig(next, { allowlist: [...existing, entry] });
}
}
// Ask about blocklist
const addBlocklist = await prompter.confirm({
message: "Add senders to blocklist? (Block spam/unwanted emails)",
initialValue: false,
});
if (addBlocklist) {
const entry = String(
await prompter.text({
message: "Email or domain to block (e.g., spam@bad.com or bad.com)",
})
).trim();
if (entry) {
const existing =
(next as CoreConfig).channels?.agentmail?.blocklist ?? [];
next = updateAgentMailConfig(next, { blocklist: [...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, Clawdbot won't receive incoming emails.",
].join("\n"),
"Webhook Setup Required"
);
}
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> {
// Check for existing inboxes
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> {
// eslint-disable-next-line no-constant-condition
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 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) {
await prompter.note(
`${targetEmail} is already taken. Please try a different address.`,
"Address Unavailable"
);
continue;
}
throw new Error(`Failed to create inbox: ${message}`);
}
}
}

View File

@ -0,0 +1,49 @@
import type { AgentMailClient } from "agentmail";
import type { ChannelOutboundAdapter } from "clawdbot/plugin-sdk";
import { getClientAndInbox } from "./client.js";
import { getAgentMailRuntime } from "./runtime.js";
/** Sends a reply to an email message via AgentMail. */
export async function sendAgentMailReply(params: {
client: AgentMailClient;
inboxId: string;
messageId: string;
text: string;
html?: string;
}): Promise<{ messageId: string; threadId: string }> {
const { client, inboxId, messageId, text, html } = params;
return client.inboxes.messages.reply(inboxId, messageId, { text, html });
}
/** Sends a message (reply or new) and returns standardized result. */
async function sendMessage(params: {
to: string;
text: string;
html?: string;
replyToId?: string;
}): Promise<{ channel: "agentmail"; messageId: string; threadId: string }> {
const { to, text, html, replyToId } = params;
const { client, inboxId } = getClientAndInbox();
const result = replyToId
? await client.inboxes.messages.reply(inboxId, replyToId, { text, html })
: await client.inboxes.messages.send(inboxId, { to: [to], text, html });
return { channel: "agentmail", ...result };
}
/** Outbound adapter for the AgentMail channel. */
export const agentmailOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getAgentMailRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: ({ to, text, replyToId }) => sendMessage({ to, text, replyToId: replyToId ?? undefined }),
sendMedia: ({ to, text, mediaUrl, replyToId }) => {
const fullText = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
return sendMessage({ to, text: fullText, replyToId: replyToId ?? undefined });
},
};

View File

@ -0,0 +1,12 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setAgentMailRuntime(next: PluginRuntime) {
runtime = next;
}
export function getAgentMailRuntime(): PluginRuntime {
if (!runtime) throw new Error("AgentMail runtime not initialized");
return runtime;
}

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { extractMessageBody } from "./thread.js";
describe("extractMessageBody", () => {
it("prefers extractedText", () => {
const result = extractMessageBody({
extractedText: "new content only",
extractedHtml: "<p>new html</p>",
text: "full text",
html: "<p>full html</p>",
});
expect(result).toBe("new content only");
});
it("falls back to extractedHtml", () => {
const result = extractMessageBody({
extractedText: undefined,
extractedHtml: "<p>new html</p>",
text: "full text",
html: "<p>full html</p>",
});
expect(result).toBe("<p>new html</p>");
});
it("falls back to text", () => {
const result = extractMessageBody({
extractedText: undefined,
extractedHtml: undefined,
text: "full text",
html: "<p>full html</p>",
});
expect(result).toBe("full text");
});
it("falls back to html", () => {
const result = extractMessageBody({
extractedText: undefined,
extractedHtml: undefined,
text: undefined,
html: "<p>full html</p>",
});
expect(result).toBe("<p>full html</p>");
});
it("returns empty string when all undefined", () => {
const result = extractMessageBody({
extractedText: undefined,
extractedHtml: undefined,
text: undefined,
html: undefined,
});
expect(result).toBe("");
});
it("handles null values", () => {
const result = extractMessageBody({
extractedText: null as unknown as undefined,
extractedHtml: null as unknown as undefined,
text: "fallback text",
html: undefined,
});
expect(result).toBe("fallback text");
});
it("handles empty strings by falling through", () => {
// Empty string is falsy, so it falls through to next option
const result = extractMessageBody({
extractedText: "",
extractedHtml: undefined,
text: "text fallback",
html: undefined,
});
// Note: "" is falsy so it will fall through - this tests current behavior
expect(result).toBe("");
});
});

View File

@ -0,0 +1,101 @@
import type { AgentMailClient, AgentMail } from "agentmail";
import { formatAttachments } from "./attachment.js";
import { formatUtcDate } from "./utils.js";
type Thread = AgentMail.threads.Thread;
type Message = AgentMail.messages.Message;
/**
* Extracts the body text from a message, preferring extractedText.
* extractedText contains only new content (excluding quoted replies).
*/
export function extractMessageBody(
msg: Pick<Message, "extractedText" | "extractedHtml" | "text" | "html">
): string {
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");
}
/**
* 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");
}
/**
* 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} ---`,
`From: ${msg.from}`,
recipients,
];
if (attachments) {
parts.push(attachments);
}
parts.push("", body);
return parts.join("\n");
}
/**
* Fetches and formats the thread as context for the agent.
* Returns empty string if fetch fails.
*/
export async function fetchFormattedThread(
client: AgentMailClient,
inboxId: string,
threadId: string
): Promise<string> {
try {
const thread = await client.inboxes.threads.get(inboxId, threadId);
if (thread.messages.length === 0) {
return "";
}
const header = formatThreadHeader(thread);
const messages = thread.messages.map(formatMessage).join("\n\n");
return `${header}\n\n${messages}`;
} catch (err) {
console.error(`Failed to fetch thread: ${String(err)}`);
return "";
}
}

View File

@ -0,0 +1,47 @@
import { type Static, Type } from "@sinclair/typebox";
import type { ChannelAgentTool } from "clawdbot/plugin-sdk";
import { formatAttachmentResponse } from "./attachment.js";
import { getClientAndInbox } from "./client.js";
const GetAttachmentParams = Type.Object({
messageId: Type.String({ description: "The message ID containing the attachment" }),
attachmentId: Type.String({ description: "The attachment ID to fetch" }),
});
/** Creates the get_email_attachment tool for fetching attachment download URLs. */
export function createGetAttachmentTool(): ChannelAgentTool {
return {
label: "Get Email Attachment",
name: "get_email_attachment",
description:
"Fetch a temporary download URL for an email attachment. Use the attachment ID from the thread context.",
parameters: GetAttachmentParams,
execute: async (_toolCallId, args) => {
const { messageId, attachmentId } = args as Static<typeof GetAttachmentParams>;
try {
const { client, inboxId } = getClientAndInbox();
const attachment = await client.inboxes.messages.getAttachment(inboxId, messageId, attachmentId);
return {
content: [{ type: "text", text: formatAttachmentResponse(attachment) }],
details: attachment,
};
} catch (err) {
return {
content: [{ type: "text", text: `Failed to fetch attachment: ${err instanceof Error ? err.message : String(err)}` }],
details: {},
isError: true,
};
}
},
};
}
/**
* Returns all AgentMail agent tools.
*/
export function createAgentMailTools(): ChannelAgentTool[] {
return [createGetAttachmentTool()];
}

View File

@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { formatUtcDate, parseEmailFromAddress, parseNameFromAddress } from "./utils.js";
describe("formatUtcDate", () => {
it("formats Date object to UTC string", () => {
const date = new Date("2024-01-15T10:30:00Z");
const result = formatUtcDate(date);
expect(result).toContain("Mon, 15 Jan 2024");
expect(result).toContain("UTC");
expect(result).not.toContain("GMT");
});
it("formats ISO string to UTC string", () => {
const result = formatUtcDate("2024-06-20T15:45:00Z");
expect(result).toContain("Thu, 20 Jun 2024");
expect(result).toContain("UTC");
});
it("formats timestamp to UTC string", () => {
const timestamp = new Date("2024-03-10T08:00:00Z").getTime();
const result = formatUtcDate(timestamp);
expect(result).toContain("Sun, 10 Mar 2024");
});
});
describe("parseEmailFromAddress", () => {
it("extracts email from angle bracket format", () => {
expect(parseEmailFromAddress("John Doe <john@example.com>")).toBe("john@example.com");
});
it("returns plain email as-is", () => {
expect(parseEmailFromAddress("user@example.com")).toBe("user@example.com");
});
it("handles email with spaces", () => {
expect(parseEmailFromAddress(" user@example.com ")).toBe("user@example.com");
});
it("lowercases email", () => {
expect(parseEmailFromAddress("User@EXAMPLE.COM")).toBe("user@example.com");
});
it("handles complex display names", () => {
expect(parseEmailFromAddress("Dr. John Smith Jr. <john.smith@company.org>")).toBe("john.smith@company.org");
});
});
describe("parseNameFromAddress", () => {
it("extracts display name from angle bracket format", () => {
expect(parseNameFromAddress("John Doe <john@example.com>")).toBe("John Doe");
});
it("falls back to email local part for plain email", () => {
expect(parseNameFromAddress("user@example.com")).toBe("user");
});
it("handles complex display names", () => {
expect(parseNameFromAddress("Dr. Jane Smith <jane@hospital.org>")).toBe("Dr. Jane Smith");
});
it("trims whitespace from display name", () => {
expect(parseNameFromAddress(" John Doe <john@example.com>")).toBe("John Doe");
});
});

View File

@ -0,0 +1,77 @@
import type { AgentMail } from "agentmail";
import type { z } from "zod";
import type { AgentMailConfigSchema } from "./config-schema.js";
/** AgentMail channel configuration. */
export type AgentMailConfig = z.infer<typeof AgentMailConfigSchema>;
/** Core config shape with AgentMail channel. */
export type CoreConfig = {
channels?: {
agentmail?: AgentMailConfig;
};
session?: {
store?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
/** Resolved AgentMail account with runtime state. */
export type ResolvedAgentMailAccount = {
accountId: string;
/** Account name for identifying this configuration. */
name?: string;
enabled: boolean;
configured: boolean;
config: AgentMailConfig;
inboxId?: string;
};
/** SDK Message type alias. */
export type Message = AgentMail.messages.Message;
/** Re-export SDK webhook event type. */
export type MessageReceivedEvent = AgentMail.events.MessageReceivedEvent;
/** Result of sender filtering check. */
export type FilterResult = {
allowed: boolean;
blocked: boolean;
label: "allowed" | "blocked" | null;
};
/** Formats a date as a UTC string. */
export function formatUtcDate(date: Date | string | number): string {
return new Date(date).toUTCString().replace("GMT", "UTC");
}
/**
* Parses email address from a "from" string.
* Handles formats: "email@example.com" or "Display Name <email@example.com>"
*/
export function parseEmailFromAddress(from: string): string {
// Match email in angle brackets: "Display Name <email@example.com>"
const bracketMatch = /<([^>]+)>/.exec(from);
if (bracketMatch?.[1]) {
return bracketMatch[1].toLowerCase();
}
// Otherwise assume the whole string is the email
return from.trim().toLowerCase();
}
/**
* Parses display name from a "from" string.
* Returns email local part if no display name.
*/
export function parseNameFromAddress(from: string): string {
// Match display name before angle brackets
const nameMatch = /^([^<]+)</.exec(from);
if (nameMatch?.[1]) {
return nameMatch[1].trim();
}
// Fall back to email local part
const email = parseEmailFromAddress(from);
return email.split("@")[0] || email;
}

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"paths": {
"clawdbot/plugin-sdk": ["../../src/plugin-sdk/index.ts"]
}
},
"include": ["./**/*"],
"exclude": ["node_modules"]
}

225
pnpm-lock.yaml generated
View File

@ -262,6 +262,19 @@ importers:
specifier: 3.15.0
version: 3.15.0(typescript@5.9.3)
extensions/agentmail:
dependencies:
agentmail:
specifier: ^0.2.4
version: 0.2.4
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/bluebubbles: {}
extensions/copilot-proxy: {}
@ -314,7 +327,7 @@ importers:
specifier: ^10.5.0
version: 10.5.0
devDependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
@ -322,7 +335,7 @@ importers:
extensions/line:
devDependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
@ -335,12 +348,12 @@ importers:
'@matrix-org/matrix-sdk-crypto-nodejs':
specifier: ^0.4.0
version: 0.4.0
'@vector-im/matrix-bot-sdk':
specifier: 0.8.0-element.3
version: 0.8.0-element.3
markdown-it:
specifier: 14.1.0
version: 14.1.0
matrix-bot-sdk:
specifier: 0.8.0
version: 0.8.0
music-metadata:
specifier: ^11.10.6
version: 11.10.6
@ -348,7 +361,7 @@ importers:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
@ -356,7 +369,7 @@ importers:
extensions/memory-core:
devDependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
@ -383,7 +396,7 @@ importers:
'@microsoft/agents-hosting-extensions-teams':
specifier: ^1.2.2
version: 1.2.2
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
express:
@ -397,7 +410,7 @@ importers:
extensions/nostr:
dependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
nostr-tools:
@ -439,7 +452,7 @@ importers:
specifier: ^4.3.5
version: 4.3.6
devDependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
@ -459,7 +472,7 @@ importers:
extensions/zalo:
dependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
undici:
@ -471,13 +484,7 @@ importers:
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
moltbot:
specifier: workspace:*
version: link:../..
packages/clawdbot:
dependencies:
moltbot:
clawdbot:
specifier: workspace:*
version: link:../..
@ -2674,9 +2681,6 @@ packages:
'@types/bun@1.3.6':
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
'@types/caseless@0.12.5':
resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -2758,9 +2762,6 @@ packages:
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/request@2.48.13':
resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@ -2779,9 +2780,6 @@ packages:
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -2838,10 +2836,6 @@ packages:
'@urbit/http-api@3.0.0':
resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==}
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==}
engines: {node: '>=22.0.0'}
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
peerDependencies:
@ -2952,6 +2946,10 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agentmail@0.2.4:
resolution: {integrity: sha512-7+23wQUc7b/pzTbvdMkjZcimfMwgQyhQ+ONkZPGBmkDh7EbaoJtlVb/2B8/7FXV44oCPvrRqk+uvM1I/K446Gg==}
engines: {node: '>=18.0.0'}
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@ -3214,11 +3212,6 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clawdbot@2026.1.24-3:
resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==}
engines: {node: '>=22.12.0'}
hasBin: true
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@ -3636,10 +3629,6 @@ packages:
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
engines: {node: '>= 0.12'}
form-data@2.5.5:
resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==}
engines: {node: '>= 0.12'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@ -4264,6 +4253,10 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
matrix-bot-sdk@0.8.0:
resolution: {integrity: sha512-sCY5UvZfsZhJdCjSc8wZhGhIHOe5cSFSILxx9Zp5a/NEXtmQ6W/bIhefIk4zFAZXetFwXsgvKh1960k1hG5WDw==}
engines: {node: '>=22.0.0'}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@ -8444,8 +8437,6 @@ snapshots:
bun-types: 1.3.6
optional: true
'@types/caseless@0.12.5': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -8538,13 +8529,6 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/request@2.48.13':
dependencies:
'@types/caseless': 0.12.5
'@types/node': 25.0.10
'@types/tough-cookie': 4.0.5
form-data: 2.5.5
'@types/retry@0.12.0': {}
'@types/retry@0.12.5': {}
@ -8569,8 +8553,6 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/node': 25.0.10
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7': {}
'@types/ws@8.18.1':
@ -8624,30 +8606,6 @@ snapshots:
browser-or-node: 1.3.0
core-js: 3.48.0
'@vector-im/matrix-bot-sdk@0.8.0-element.3':
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
'@types/express': 4.17.25
'@types/request': 2.48.13
another-json: 0.2.0
async-lock: 1.4.1
chalk: 4.1.2
express: 4.22.1
glob-to-regexp: 0.4.1
hash.js: 1.1.7
html-to-text: 9.0.5
htmlencode: 0.0.4
lowdb: 1.0.0
lru-cache: 10.4.3
mkdirp: 3.0.1
morgan: 1.10.1
postgres: 3.4.8
request: 2.88.2
request-promise: 4.2.6(request@2.88.2)
sanitize-html: 2.17.0
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
@ -8807,6 +8765,13 @@ snapshots:
agent-base@7.1.4: {}
agentmail@0.2.4:
dependencies:
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@ -9098,84 +9063,6 @@ snapshots:
dependencies:
clsx: 2.1.1
clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
dependencies:
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.975.0
'@buape/carbon': 0.14.0(hono@4.11.4)
'@clack/prompts': 0.11.0
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
'@homebridge/ciao': 1.3.4
'@line/bot-sdk': 10.6.0
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
'@mozilla/readability': 0.6.0
'@sinclair/typebox': 0.34.47
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.13.0
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.17.1
body-parser: 2.2.2
chalk: 5.6.2
chokidar: 5.0.0
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
cli-highlight: 2.1.11
commander: 14.0.2
croner: 9.1.0
detect-libc: 2.1.2
discord-api-types: 0.38.37
dotenv: 17.2.3
express: 5.2.1
file-type: 21.3.0
grammy: 1.39.3
hono: 4.11.4
jiti: 2.6.1
json5: 2.2.3
jszip: 3.10.1
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.0
node-edge-tts: 1.2.9
osc-progress: 0.3.0
pdfjs-dist: 5.4.530
playwright-core: 1.58.0
proper-lockfile: 4.1.2
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.4
tslog: 4.10.2
undici: 7.19.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
optionalDependencies:
'@napi-rs/canvas': 0.1.88
node-llama-cpp: 3.15.0(typescript@5.9.3)
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
- aws-crt
- bufferutil
- canvas
- debug
- devtools-protocol
- encoding
- ffmpeg-static
- jimp
- link-preview-js
- node-opus
- opusscript
- supports-color
- typescript
- utf-8-validate
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@ -9656,15 +9543,6 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
form-data@2.5.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
safe-buffer: 5.2.1
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@ -10344,6 +10222,29 @@ snapshots:
math-intrinsics@1.1.0: {}
matrix-bot-sdk@0.8.0:
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0
'@types/express': 4.17.25
another-json: 0.2.0
async-lock: 1.4.1
chalk: 4.1.2
express: 4.22.1
glob-to-regexp: 0.4.1
hash.js: 1.1.7
html-to-text: 9.0.5
htmlencode: 0.0.4
lowdb: 1.0.0
lru-cache: 10.4.3
mkdirp: 3.0.1
morgan: 1.10.1
postgres: 3.4.8
request: 2.88.2
request-promise: 4.2.6(request@2.88.2)
sanitize-html: 2.17.0
transitivePeerDependencies:
- supports-color
mdurl@2.0.0: {}
media-typer@0.3.0: {}

View File

@ -25,7 +25,7 @@ export type ChannelSetupInput = {
signalNumber?: string;
cliPath?: string;
dbPath?: string;
service?: "imessage" | "sms" | "auto";
service?: "imessage" | "sms" | "email" | "auto";
region?: string;
authDir?: string;
httpUrl?: string;
@ -48,6 +48,7 @@ export type ChannelSetupInput = {
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
emailAddress?: string;
};
export type ChannelStatusIssue = {
@ -139,6 +140,7 @@ export type ChannelAccountSnapshot = {
audit?: unknown;
application?: unknown;
bot?: unknown;
emailAddress?: string | null;
};
export type ChannelLogSink = {