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":
|
"channel: bluebubbles":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- 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
|
specifier: 3.15.0
|
||||||
version: 3.15.0(typescript@5.9.3)
|
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/bluebubbles: {}
|
||||||
|
|
||||||
extensions/copilot-proxy: {}
|
extensions/copilot-proxy: {}
|
||||||
@ -314,7 +327,7 @@ importers:
|
|||||||
specifier: ^10.5.0
|
specifier: ^10.5.0
|
||||||
version: 10.5.0
|
version: 10.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -322,7 +335,7 @@ importers:
|
|||||||
|
|
||||||
extensions/line:
|
extensions/line:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -335,12 +348,12 @@ importers:
|
|||||||
'@matrix-org/matrix-sdk-crypto-nodejs':
|
'@matrix-org/matrix-sdk-crypto-nodejs':
|
||||||
specifier: ^0.4.0
|
specifier: ^0.4.0
|
||||||
version: 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:
|
markdown-it:
|
||||||
specifier: 14.1.0
|
specifier: 14.1.0
|
||||||
version: 14.1.0
|
version: 14.1.0
|
||||||
|
matrix-bot-sdk:
|
||||||
|
specifier: 0.8.0
|
||||||
|
version: 0.8.0
|
||||||
music-metadata:
|
music-metadata:
|
||||||
specifier: ^11.10.6
|
specifier: ^11.10.6
|
||||||
version: 11.10.6
|
version: 11.10.6
|
||||||
@ -348,7 +361,7 @@ importers:
|
|||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -356,7 +369,7 @@ importers:
|
|||||||
|
|
||||||
extensions/memory-core:
|
extensions/memory-core:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -383,7 +396,7 @@ importers:
|
|||||||
'@microsoft/agents-hosting-extensions-teams':
|
'@microsoft/agents-hosting-extensions-teams':
|
||||||
specifier: ^1.2.2
|
specifier: ^1.2.2
|
||||||
version: 1.2.2
|
version: 1.2.2
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
express:
|
express:
|
||||||
@ -397,7 +410,7 @@ importers:
|
|||||||
|
|
||||||
extensions/nostr:
|
extensions/nostr:
|
||||||
dependencies:
|
dependencies:
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
nostr-tools:
|
nostr-tools:
|
||||||
@ -439,7 +452,7 @@ importers:
|
|||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -459,7 +472,7 @@ importers:
|
|||||||
|
|
||||||
extensions/zalo:
|
extensions/zalo:
|
||||||
dependencies:
|
dependencies:
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
undici:
|
undici:
|
||||||
@ -471,13 +484,7 @@ importers:
|
|||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: 0.34.47
|
specifier: 0.34.47
|
||||||
version: 0.34.47
|
version: 0.34.47
|
||||||
moltbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../..
|
|
||||||
|
|
||||||
packages/clawdbot:
|
|
||||||
dependencies:
|
|
||||||
moltbot:
|
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
@ -2674,9 +2681,6 @@ packages:
|
|||||||
'@types/bun@1.3.6':
|
'@types/bun@1.3.6':
|
||||||
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
|
resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==}
|
||||||
|
|
||||||
'@types/caseless@0.12.5':
|
|
||||||
resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
|
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@ -2758,9 +2762,6 @@ packages:
|
|||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
'@types/request@2.48.13':
|
|
||||||
resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
|
|
||||||
|
|
||||||
'@types/retry@0.12.0':
|
'@types/retry@0.12.0':
|
||||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||||
|
|
||||||
@ -2779,9 +2780,6 @@ packages:
|
|||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
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':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@ -2838,10 +2836,6 @@ packages:
|
|||||||
'@urbit/http-api@3.0.0':
|
'@urbit/http-api@3.0.0':
|
||||||
resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==}
|
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':
|
'@vitest/browser-playwright@4.0.18':
|
||||||
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
|
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2952,6 +2946,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
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:
|
ajv-formats@3.0.1:
|
||||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -3214,11 +3212,6 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
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:
|
cli-cursor@5.0.0:
|
||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -3636,10 +3629,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
|
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
|
||||||
engines: {node: '>= 0.12'}
|
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:
|
form-data@4.0.5:
|
||||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@ -4264,6 +4253,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
mdurl@2.0.0:
|
||||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||||
|
|
||||||
@ -8444,8 +8437,6 @@ snapshots:
|
|||||||
bun-types: 1.3.6
|
bun-types: 1.3.6
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@types/caseless@0.12.5': {}
|
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
@ -8538,13 +8529,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@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.0': {}
|
||||||
|
|
||||||
'@types/retry@0.12.5': {}
|
'@types/retry@0.12.5': {}
|
||||||
@ -8569,8 +8553,6 @@ snapshots:
|
|||||||
'@types/http-errors': 2.0.5
|
'@types/http-errors': 2.0.5
|
||||||
'@types/node': 25.0.10
|
'@types/node': 25.0.10
|
||||||
|
|
||||||
'@types/tough-cookie@4.0.5': {}
|
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
@ -8624,30 +8606,6 @@ snapshots:
|
|||||||
browser-or-node: 1.3.0
|
browser-or-node: 1.3.0
|
||||||
core-js: 3.48.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)':
|
'@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:
|
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)
|
'@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: {}
|
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):
|
ajv-formats@3.0.1(ajv@8.17.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.17.1
|
ajv: 8.17.1
|
||||||
@ -9098,84 +9063,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
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:
|
cli-cursor@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
@ -9656,15 +9543,6 @@ snapshots:
|
|||||||
combined-stream: 1.0.8
|
combined-stream: 1.0.8
|
||||||
mime-types: 2.1.35
|
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:
|
form-data@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
asynckit: 0.4.0
|
asynckit: 0.4.0
|
||||||
@ -10344,6 +10222,29 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
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: {}
|
mdurl@2.0.0: {}
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export type ChannelSetupInput = {
|
|||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
service?: "imessage" | "sms" | "auto";
|
service?: "imessage" | "sms" | "email" | "auto";
|
||||||
region?: string;
|
region?: string;
|
||||||
authDir?: string;
|
authDir?: string;
|
||||||
httpUrl?: string;
|
httpUrl?: string;
|
||||||
@ -48,6 +48,7 @@ export type ChannelSetupInput = {
|
|||||||
groupChannels?: string[];
|
groupChannels?: string[];
|
||||||
dmAllowlist?: string[];
|
dmAllowlist?: string[];
|
||||||
autoDiscoverChannels?: boolean;
|
autoDiscoverChannels?: boolean;
|
||||||
|
emailAddress?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelStatusIssue = {
|
export type ChannelStatusIssue = {
|
||||||
@ -139,6 +140,7 @@ export type ChannelAccountSnapshot = {
|
|||||||
audit?: unknown;
|
audit?: unknown;
|
||||||
application?: unknown;
|
application?: unknown;
|
||||||
bot?: unknown;
|
bot?: unknown;
|
||||||
|
emailAddress?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelLogSink = {
|
export type ChannelLogSink = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user