diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..4ad0e8cf8 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,3 +1,8 @@ +"channel: agentmail": + - changed-files: + - any-glob-to-any-file: + - "extensions/agentmail/**" + - "docs/channels/agentmail.md" "channel: bluebubbles": - changed-files: - any-glob-to-any-file: diff --git a/docs/channels/agentmail.md b/docs/channels/agentmail.md new file mode 100644 index 000000000..e6d0d2d1c --- /dev/null +++ b/docs/channels/agentmail.md @@ -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 diff --git a/extensions/agentmail/CHANGELOG.md b/extensions/agentmail/CHANGELOG.md new file mode 100644 index 000000000..c8c1222c7 --- /dev/null +++ b/extensions/agentmail/CHANGELOG.md @@ -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 diff --git a/extensions/agentmail/README.md b/extensions/agentmail/README.md new file mode 100644 index 000000000..eb98b9588 --- /dev/null +++ b/extensions/agentmail/README.md @@ -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. diff --git a/extensions/agentmail/clawdbot.plugin.json b/extensions/agentmail/clawdbot.plugin.json new file mode 100644 index 000000000..820d78cd0 --- /dev/null +++ b/extensions/agentmail/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "agentmail", + "channels": [ + "agentmail" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/agentmail/index.ts b/extensions/agentmail/index.ts new file mode 100644 index 000000000..8a00004c5 --- /dev/null +++ b/extensions/agentmail/index.ts @@ -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; diff --git a/extensions/agentmail/package.json b/extensions/agentmail/package.json new file mode 100644 index 000000000..9a4277a81 --- /dev/null +++ b/extensions/agentmail/package.json @@ -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:*" + } +} diff --git a/extensions/agentmail/src/accounts.test.ts b/extensions/agentmail/src/accounts.test.ts new file mode 100644 index 000000000..011c1e32f --- /dev/null +++ b/extensions/agentmail/src/accounts.test.ts @@ -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"); + }); +}); diff --git a/extensions/agentmail/src/accounts.ts b/extensions/agentmail/src/accounts.ts new file mode 100644 index 000000000..4fd363cd1 --- /dev/null +++ b/extensions/agentmail/src/accounts.ts @@ -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 = 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, + }; +} diff --git a/extensions/agentmail/src/attachment.test.ts b/extensions/agentmail/src/attachment.test.ts new file mode 100644 index 000000000..b60723075 --- /dev/null +++ b/extensions/agentmail/src/attachment.test.ts @@ -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"); + }); +}); diff --git a/extensions/agentmail/src/attachment.ts b/extensions/agentmail/src/attachment.ts new file mode 100644 index 000000000..d2ca514ae --- /dev/null +++ b/extensions/agentmail/src/attachment.ts @@ -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"); +} diff --git a/extensions/agentmail/src/channel.ts b/extensions/agentmail/src/channel.ts new file mode 100644 index 000000000..9b80a7388 --- /dev/null +++ b/extensions/agentmail/src/channel.ts @@ -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 = { + 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: "", + }, + }, + + 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(), +}; diff --git a/extensions/agentmail/src/client.ts b/extensions/agentmail/src/client.ts new file mode 100644 index 000000000..9022b2d09 --- /dev/null +++ b/extensions/agentmail/src/client.ts @@ -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 }; +} + diff --git a/extensions/agentmail/src/config-schema.ts b/extensions/agentmail/src/config-schema.ts new file mode 100644 index 000000000..3567826de --- /dev/null +++ b/extensions/agentmail/src/config-schema.ts @@ -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(), +}); diff --git a/extensions/agentmail/src/filtering.test.ts b/extensions/agentmail/src/filtering.test.ts new file mode 100644 index 000000000..27e741226 --- /dev/null +++ b/extensions/agentmail/src/filtering.test.ts @@ -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" }); + }); +}); diff --git a/extensions/agentmail/src/filtering.ts b/extensions/agentmail/src/filtering.ts new file mode 100644 index 000000000..01b515e20 --- /dev/null +++ b/extensions/agentmail/src/filtering.ts @@ -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, +): 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 { + 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, +): Promise { + const result = checkSenderFilter(senderEmail, config); + + if (result.label) { + await labelMessage(client, inboxId, messageId, result.label); + } + + return result; +} diff --git a/extensions/agentmail/src/monitor.ts b/extensions/agentmail/src/monitor.ts new file mode 100644 index 000000000..de4b4e66b --- /dev/null +++ b/extensions/agentmail/src/monitor.ts @@ -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(); + +function recordState(accountId: string, state: Partial ? 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 { + 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): 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 { + 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 " 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((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 }); + }); +} diff --git a/extensions/agentmail/src/onboarding.test.ts b/extensions/agentmail/src/onboarding.test.ts new file mode 100644 index 000000000..d93faf555 --- /dev/null +++ b/extensions/agentmail/src/onboarding.test.ts @@ -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"); + }); +}); diff --git a/extensions/agentmail/src/onboarding.ts b/extensions/agentmail/src/onboarding.ts new file mode 100644 index 000000000..91e0ea453 --- /dev/null +++ b/extensions/agentmail/src/onboarding.ts @@ -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 { + 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 { + 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 +): ClawdbotConfig { + const channels = (cfg.channels ?? {}) as Record; + 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 { + // 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 { + // 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}`); + } + } +} diff --git a/extensions/agentmail/src/outbound.ts b/extensions/agentmail/src/outbound.ts new file mode 100644 index 000000000..47f15f8ec --- /dev/null +++ b/extensions/agentmail/src/outbound.ts @@ -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 }); + }, +}; diff --git a/extensions/agentmail/src/runtime.ts b/extensions/agentmail/src/runtime.ts new file mode 100644 index 000000000..43bbff28f --- /dev/null +++ b/extensions/agentmail/src/runtime.ts @@ -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; +} diff --git a/extensions/agentmail/src/thread.test.ts b/extensions/agentmail/src/thread.test.ts new file mode 100644 index 000000000..45fd4f307 --- /dev/null +++ b/extensions/agentmail/src/thread.test.ts @@ -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: "

new html

", + text: "full text", + html: "

full html

", + }); + expect(result).toBe("new content only"); + }); + + it("falls back to extractedHtml", () => { + const result = extractMessageBody({ + extractedText: undefined, + extractedHtml: "

new html

", + text: "full text", + html: "

full html

", + }); + expect(result).toBe("

new html

"); + }); + + it("falls back to text", () => { + const result = extractMessageBody({ + extractedText: undefined, + extractedHtml: undefined, + text: "full text", + html: "

full html

", + }); + expect(result).toBe("full text"); + }); + + it("falls back to html", () => { + const result = extractMessageBody({ + extractedText: undefined, + extractedHtml: undefined, + text: undefined, + html: "

full html

", + }); + expect(result).toBe("

full html

"); + }); + + 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(""); + }); +}); diff --git a/extensions/agentmail/src/thread.ts b/extensions/agentmail/src/thread.ts new file mode 100644 index 000000000..55308a8e7 --- /dev/null +++ b/extensions/agentmail/src/thread.ts @@ -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 +): 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 { + 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 ""; + } +} diff --git a/extensions/agentmail/src/tools.ts b/extensions/agentmail/src/tools.ts new file mode 100644 index 000000000..a75e712bb --- /dev/null +++ b/extensions/agentmail/src/tools.ts @@ -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; + + 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()]; +} diff --git a/extensions/agentmail/src/utils.test.ts b/extensions/agentmail/src/utils.test.ts new file mode 100644 index 000000000..45126a799 --- /dev/null +++ b/extensions/agentmail/src/utils.test.ts @@ -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 ")).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. ")).toBe("john.smith@company.org"); + }); +}); + +describe("parseNameFromAddress", () => { + it("extracts display name from angle bracket format", () => { + expect(parseNameFromAddress("John Doe ")).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 ")).toBe("Dr. Jane Smith"); + }); + + it("trims whitespace from display name", () => { + expect(parseNameFromAddress(" John Doe ")).toBe("John Doe"); + }); +}); diff --git a/extensions/agentmail/src/utils.ts b/extensions/agentmail/src/utils.ts new file mode 100644 index 000000000..7b12d5d67 --- /dev/null +++ b/extensions/agentmail/src/utils.ts @@ -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; + +/** 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 " + */ +export function parseEmailFromAddress(from: string): string { + // Match email in angle brackets: "Display Name " + 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 = /^([^<]+)=22.0.0'} - '@vitest/browser-playwright@4.0.18': resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} peerDependencies: @@ -2952,6 +2946,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agentmail@0.2.4: + resolution: {integrity: sha512-7+23wQUc7b/pzTbvdMkjZcimfMwgQyhQ+ONkZPGBmkDh7EbaoJtlVb/2B8/7FXV44oCPvrRqk+uvM1I/K446Gg==} + engines: {node: '>=18.0.0'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3214,11 +3212,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3636,10 +3629,6 @@ packages: resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} engines: {node: '>= 0.12'} - form-data@2.5.5: - resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} - engines: {node: '>= 0.12'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -4264,6 +4253,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + matrix-bot-sdk@0.8.0: + resolution: {integrity: sha512-sCY5UvZfsZhJdCjSc8wZhGhIHOe5cSFSILxx9Zp5a/NEXtmQ6W/bIhefIk4zFAZXetFwXsgvKh1960k1hG5WDw==} + engines: {node: '>=22.0.0'} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -8444,8 +8437,6 @@ snapshots: bun-types: 1.3.6 optional: true - '@types/caseless@0.12.5': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8538,13 +8529,6 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/request@2.48.13': - dependencies: - '@types/caseless': 0.12.5 - '@types/node': 25.0.10 - '@types/tough-cookie': 4.0.5 - form-data: 2.5.5 - '@types/retry@0.12.0': {} '@types/retry@0.12.5': {} @@ -8569,8 +8553,6 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.0.10 - '@types/tough-cookie@4.0.5': {} - '@types/trusted-types@2.0.7': {} '@types/ws@8.18.1': @@ -8624,30 +8606,6 @@ snapshots: browser-or-node: 1.3.0 core-js: 3.48.0 - '@vector-im/matrix-bot-sdk@0.8.0-element.3': - dependencies: - '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0 - '@types/express': 4.17.25 - '@types/request': 2.48.13 - another-json: 0.2.0 - async-lock: 1.4.1 - chalk: 4.1.2 - express: 4.22.1 - glob-to-regexp: 0.4.1 - hash.js: 1.1.7 - html-to-text: 9.0.5 - htmlencode: 0.0.4 - lowdb: 1.0.0 - lru-cache: 10.4.3 - mkdirp: 3.0.1 - morgan: 1.10.1 - postgres: 3.4.8 - request: 2.88.2 - request-promise: 4.2.6(request@2.88.2) - sanitize-html: 2.17.0 - transitivePeerDependencies: - - supports-color - '@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) @@ -8807,6 +8765,13 @@ snapshots: agent-base@7.1.4: {} + agentmail@0.2.4: + dependencies: + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -9098,84 +9063,6 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): - dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -9656,15 +9543,6 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 - form-data@2.5.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - safe-buffer: 5.2.1 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -10344,6 +10222,29 @@ snapshots: math-intrinsics@1.1.0: {} + matrix-bot-sdk@0.8.0: + dependencies: + '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0 + '@types/express': 4.17.25 + another-json: 0.2.0 + async-lock: 1.4.1 + chalk: 4.1.2 + express: 4.22.1 + glob-to-regexp: 0.4.1 + hash.js: 1.1.7 + html-to-text: 9.0.5 + htmlencode: 0.0.4 + lowdb: 1.0.0 + lru-cache: 10.4.3 + mkdirp: 3.0.1 + morgan: 1.10.1 + postgres: 3.4.8 + request: 2.88.2 + request-promise: 4.2.6(request@2.88.2) + sanitize-html: 2.17.0 + transitivePeerDependencies: + - supports-color + mdurl@2.0.0: {} media-typer@0.3.0: {} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index dcedc0a9c..fd8b4e965 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -25,7 +25,7 @@ export type ChannelSetupInput = { signalNumber?: string; cliPath?: string; dbPath?: string; - service?: "imessage" | "sms" | "auto"; + service?: "imessage" | "sms" | "email" | "auto"; region?: string; authDir?: string; httpUrl?: string; @@ -48,6 +48,7 @@ export type ChannelSetupInput = { groupChannels?: string[]; dmAllowlist?: string[]; autoDiscoverChannels?: boolean; + emailAddress?: string; }; export type ChannelStatusIssue = { @@ -139,6 +140,7 @@ export type ChannelAccountSnapshot = { audit?: unknown; application?: unknown; bot?: unknown; + emailAddress?: string | null; }; export type ChannelLogSink = {