Initilal commit
This commit is contained in:
parent
87f3a59e3d
commit
a83725da19
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@ -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
192
docs/channels/agentmail.md
Normal 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
|
||||
22
extensions/agentmail/CHANGELOG.md
Normal file
22
extensions/agentmail/CHANGELOG.md
Normal 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
|
||||
59
extensions/agentmail/README.md
Normal file
59
extensions/agentmail/README.md
Normal 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.
|
||||
11
extensions/agentmail/clawdbot.plugin.json
Normal file
11
extensions/agentmail/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "agentmail",
|
||||
"channels": [
|
||||
"agentmail"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
18
extensions/agentmail/index.ts
Normal file
18
extensions/agentmail/index.ts
Normal 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;
|
||||
33
extensions/agentmail/package.json
Normal file
33
extensions/agentmail/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
199
extensions/agentmail/src/accounts.test.ts
Normal file
199
extensions/agentmail/src/accounts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
extensions/agentmail/src/accounts.ts
Normal file
66
extensions/agentmail/src/accounts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
115
extensions/agentmail/src/attachment.test.ts
Normal file
115
extensions/agentmail/src/attachment.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
44
extensions/agentmail/src/attachment.ts
Normal file
44
extensions/agentmail/src/attachment.ts
Normal 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");
|
||||
}
|
||||
276
extensions/agentmail/src/channel.ts
Normal file
276
extensions/agentmail/src/channel.ts
Normal 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(),
|
||||
};
|
||||
31
extensions/agentmail/src/client.ts
Normal file
31
extensions/agentmail/src/client.ts
Normal 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 };
|
||||
}
|
||||
|
||||
24
extensions/agentmail/src/config-schema.ts
Normal file
24
extensions/agentmail/src/config-schema.ts
Normal 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(),
|
||||
});
|
||||
75
extensions/agentmail/src/filtering.test.ts
Normal file
75
extensions/agentmail/src/filtering.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
91
extensions/agentmail/src/filtering.ts
Normal file
91
extensions/agentmail/src/filtering.ts
Normal 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;
|
||||
}
|
||||
322
extensions/agentmail/src/monitor.ts
Normal file
322
extensions/agentmail/src/monitor.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
128
extensions/agentmail/src/onboarding.test.ts
Normal file
128
extensions/agentmail/src/onboarding.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
416
extensions/agentmail/src/onboarding.ts
Normal file
416
extensions/agentmail/src/onboarding.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
extensions/agentmail/src/outbound.ts
Normal file
49
extensions/agentmail/src/outbound.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
12
extensions/agentmail/src/runtime.ts
Normal file
12
extensions/agentmail/src/runtime.ts
Normal 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;
|
||||
}
|
||||
77
extensions/agentmail/src/thread.test.ts
Normal file
77
extensions/agentmail/src/thread.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
101
extensions/agentmail/src/thread.ts
Normal file
101
extensions/agentmail/src/thread.ts
Normal 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 "";
|
||||
}
|
||||
}
|
||||
47
extensions/agentmail/src/tools.ts
Normal file
47
extensions/agentmail/src/tools.ts
Normal 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()];
|
||||
}
|
||||
65
extensions/agentmail/src/utils.test.ts
Normal file
65
extensions/agentmail/src/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
77
extensions/agentmail/src/utils.ts
Normal file
77
extensions/agentmail/src/utils.ts
Normal 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;
|
||||
}
|
||||
18
extensions/agentmail/tsconfig.json
Normal file
18
extensions/agentmail/tsconfig.json
Normal 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
225
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user