decodingtrust-agent-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent/__init__.py +30 -0
- agent/claudesdk/__init__.py +8 -0
- agent/claudesdk/example.py +221 -0
- agent/claudesdk/src/__init__.py +8 -0
- agent/claudesdk/src/agent.py +400 -0
- agent/claudesdk/src/mcp_proxy.py +409 -0
- agent/claudesdk/src/utils.py +420 -0
- agent/googleadk/__init__.py +15 -0
- agent/googleadk/example.py +237 -0
- agent/googleadk/src/__init__.py +12 -0
- agent/googleadk/src/agent.py +401 -0
- agent/googleadk/src/mcp_wrapper.py +163 -0
- agent/googleadk/src/utils.py +602 -0
- agent/langchain/__init__.py +8 -0
- agent/langchain/example.py +213 -0
- agent/langchain/src/__init__.py +8 -0
- agent/langchain/src/agent.py +645 -0
- agent/langchain/src/utils.py +433 -0
- agent/openaisdk/__init__.py +17 -0
- agent/openaisdk/example.py +228 -0
- agent/openaisdk/src/__init__.py +12 -0
- agent/openaisdk/src/agent.py +491 -0
- agent/openaisdk/src/agent_wrapper.py +143 -0
- agent/openaisdk/src/mcp_wrapper.py +395 -0
- agent/openaisdk/src/utils.py +493 -0
- agent/openclaw/__init__.py +10 -0
- agent/openclaw/example.py +251 -0
- agent/openclaw/src/__init__.py +14 -0
- agent/openclaw/src/agent.py +930 -0
- agent/openclaw/src/helpers/__init__.py +1 -0
- agent/openclaw/src/helpers/auth_helpers.py +55 -0
- agent/openclaw/src/mcp_proxy.py +564 -0
- agent/openclaw/src/plugin_generator.py +231 -0
- agent/openclaw/src/utils.py +341 -0
- agent/pocketflow/__init__.py +18 -0
- agent/pocketflow/example.py +221 -0
- agent/pocketflow/prompts/react_agent.py +46 -0
- agent/pocketflow/src/__init__.py +6 -0
- agent/pocketflow/src/agent.py +507 -0
- agent/pocketflow/src/agent_wrapper.py +159 -0
- agent/pocketflow/src/async_helper.py +92 -0
- agent/pocketflow/src/mcp_react_agent.py +279 -0
- agent/pocketflow/src/native_agent.py +74 -0
- agent/pocketflow/src/nodes.py +467 -0
- benchmark/__init__.py +0 -0
- benchmark/browser/benign.jsonl +34 -0
- benchmark/browser/direct.jsonl +85 -0
- benchmark/browser/indirect.jsonl +82 -0
- benchmark/code/benign.jsonl +0 -0
- benchmark/code/direct.jsonl +121 -0
- benchmark/code/indirect.jsonl +165 -0
- benchmark/crm/benign.jsonl +165 -0
- benchmark/crm/direct.jsonl +90 -0
- benchmark/crm/indirect.jsonl +150 -0
- benchmark/customer-service/benign.jsonl +160 -0
- benchmark/customer-service/direct.jsonl +100 -0
- benchmark/customer-service/indirect.jsonl +101 -0
- benchmark/finance/benign.jsonl +0 -0
- benchmark/finance/direct.jsonl +200 -0
- benchmark/finance/indirect.jsonl +200 -0
- benchmark/legal/benign.jsonl +0 -0
- benchmark/legal/direct.jsonl +200 -0
- benchmark/legal/indirect.jsonl +200 -0
- benchmark/macos/benign.jsonl +30 -0
- benchmark/macos/direct.jsonl +50 -0
- benchmark/macos/indirect.jsonl +50 -0
- benchmark/medical/benign.jsonl +642 -0
- benchmark/medical/direct.jsonl +229 -0
- benchmark/medical/indirect.jsonl +222 -0
- benchmark/os-filesystem/benign.jsonl +200 -0
- benchmark/os-filesystem/direct.jsonl +200 -0
- benchmark/os-filesystem/indirect.jsonl +200 -0
- benchmark/research/benign.jsonl +0 -0
- benchmark/research/direct.jsonl +119 -0
- benchmark/research/indirect.jsonl +125 -0
- benchmark/telecom/benign.jsonl +120 -0
- benchmark/telecom/direct.jsonl +161 -0
- benchmark/telecom/indirect.jsonl +166 -0
- benchmark/travel/benign.jsonl +130 -0
- benchmark/travel/direct.jsonl +105 -0
- benchmark/travel/indirect.jsonl +120 -0
- benchmark/windows/benign.jsonl +100 -0
- benchmark/windows/direct.jsonl +140 -0
- benchmark/windows/indirect.jsonl +107 -0
- benchmark/workflow/benign.jsonl +335 -0
- benchmark/workflow/direct.jsonl +78 -0
- benchmark/workflow/indirect.jsonl +107 -0
- cli/__init__.py +5 -0
- cli/main.py +182 -0
- cli/scaffold.py +334 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/METADATA +642 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/RECORD +374 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/WHEEL +5 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/top_level.txt +6 -0
- dt_arena/config/env.yaml +515 -0
- dt_arena/config/injection_mcp.yaml +430 -0
- dt_arena/config/mcp.yaml +642 -0
- dt_arena/envs/arxiv/docker-compose-hub.yml +31 -0
- dt_arena/envs/arxiv/docker-compose.yml +36 -0
- dt_arena/envs/atlassian/docker/docker-compose.dev.yml +65 -0
- dt_arena/envs/atlassian/docker/docker-compose.yml +53 -0
- dt_arena/envs/atlassian/docker-compose-hub.yml +57 -0
- dt_arena/envs/atlassian/docker-compose.yml +72 -0
- dt_arena/envs/bigquery/docker-compose.yml +20 -0
- dt_arena/envs/booking/docker-compose.yml +59 -0
- dt_arena/envs/calendar/docker-compose-hub.yml +30 -0
- dt_arena/envs/calendar/docker-compose.yml +42 -0
- dt_arena/envs/custom-website/docker-compose.yml +6 -0
- dt_arena/envs/customer_service/docker-compose.yml +59 -0
- dt_arena/envs/databricks/docker-compose-hub.yml +47 -0
- dt_arena/envs/databricks/docker-compose.yml +51 -0
- dt_arena/envs/ecommerce/docker-compose.yml +6 -0
- dt_arena/envs/ers/docker-compose.yml +36 -0
- dt_arena/envs/ers/hrms/docker/docker-compose.yml +31 -0
- dt_arena/envs/finance/docker-compose.yml +23 -0
- dt_arena/envs/github/docker/docker-compose-hub.yml +50 -0
- dt_arena/envs/github/docker/docker-compose.yml +50 -0
- dt_arena/envs/gmail/docker-compose-hub.yml +51 -0
- dt_arena/envs/gmail/docker-compose.yml +65 -0
- dt_arena/envs/google-form/docker-compose-hub.yml +33 -0
- dt_arena/envs/google-form/docker-compose.yml +41 -0
- dt_arena/envs/googledocs/docker-compose-hub.yml +61 -0
- dt_arena/envs/googledocs/docker-compose.yml +78 -0
- dt_arena/envs/hospital/docker-compose-hub.yml +25 -0
- dt_arena/envs/hospital/docker-compose.yml +27 -0
- dt_arena/envs/legal/docker-compose.yml +22 -0
- dt_arena/envs/linkedin/docker-compose.yml +63 -0
- dt_arena/envs/macos/docker-compose.yml +79 -0
- dt_arena/envs/os-filesystem/docker-compose-hub.yml +16 -0
- dt_arena/envs/os-filesystem/docker-compose.yml +20 -0
- dt_arena/envs/paypal/docker-compose-hub.yml +48 -0
- dt_arena/envs/paypal/docker-compose.yml +63 -0
- dt_arena/envs/research/docker-compose-hub.yml +13 -0
- dt_arena/envs/research/docker-compose.yml +24 -0
- dt_arena/envs/salesforce_crm/docker-compose-hub.yaml +45 -0
- dt_arena/envs/salesforce_crm/docker-compose.yaml +49 -0
- dt_arena/envs/slack/docker-compose-hub.yml +28 -0
- dt_arena/envs/slack/docker-compose.yml +41 -0
- dt_arena/envs/snowflake/docker-compose-hub.yml +41 -0
- dt_arena/envs/snowflake/docker-compose.yml +44 -0
- dt_arena/envs/telecom/docker-compose-hub.yml +16 -0
- dt_arena/envs/telecom/docker-compose.yml +17 -0
- dt_arena/envs/telegram/docker-compose-hub.yml +57 -0
- dt_arena/envs/telegram/docker-compose.yml +62 -0
- dt_arena/envs/terminal/docker-compose-hub.yml +12 -0
- dt_arena/envs/terminal/docker-compose.yml +26 -0
- dt_arena/envs/travel/docker-compose-hub.yml +19 -0
- dt_arena/envs/travel/docker-compose.yml +19 -0
- dt_arena/envs/whatsapp/docker-compose-hub.yml +61 -0
- dt_arena/envs/whatsapp/docker-compose.yml +78 -0
- dt_arena/envs/windows/docker-compose.yml +71 -0
- dt_arena/envs/zoom/docker-compose-hub.yml +27 -0
- dt_arena/envs/zoom/docker-compose.yml +40 -0
- dt_arena/injection_mcp_server/atlassian/env_injection.py +134 -0
- dt_arena/injection_mcp_server/calendar/env_injection.py +217 -0
- dt_arena/injection_mcp_server/custom_website/env_injection.py +97 -0
- dt_arena/injection_mcp_server/customer_service/env_injection.py +659 -0
- dt_arena/injection_mcp_server/databricks/env_injection.py +255 -0
- dt_arena/injection_mcp_server/ecommerce/env_injection.py +110 -0
- dt_arena/injection_mcp_server/finance/env_injection.py +85 -0
- dt_arena/injection_mcp_server/github/env_injection.py +206 -0
- dt_arena/injection_mcp_server/gmail/env_injection.py +211 -0
- dt_arena/injection_mcp_server/google_form/env_injection.py +186 -0
- dt_arena/injection_mcp_server/googledocs/env_injection.py +44 -0
- dt_arena/injection_mcp_server/hospital/env_injection.py +43 -0
- dt_arena/injection_mcp_server/legal/env_injection.py +229 -0
- dt_arena/injection_mcp_server/macos/env_injection.py +272 -0
- dt_arena/injection_mcp_server/os-filesystem/env_injection.py +341 -0
- dt_arena/injection_mcp_server/paypal/env_injection.py +268 -0
- dt_arena/injection_mcp_server/research/env_injection.py +616 -0
- dt_arena/injection_mcp_server/salesforce/env_injection.py +514 -0
- dt_arena/injection_mcp_server/slack/env_injection.py +265 -0
- dt_arena/injection_mcp_server/snowflake/env_injection.py +230 -0
- dt_arena/injection_mcp_server/telecom/env_injection.py +503 -0
- dt_arena/injection_mcp_server/telegram/env_injection.py +171 -0
- dt_arena/injection_mcp_server/terminal/env_injection.py +523 -0
- dt_arena/injection_mcp_server/travel/env_injection.py +173 -0
- dt_arena/injection_mcp_server/whatsapp/env_injection.py +185 -0
- dt_arena/injection_mcp_server/windows/env_injection.py +943 -0
- dt_arena/injection_mcp_server/zoom/env_injection.py +216 -0
- dt_arena/mcp_server/atlassian/main.py +1554 -0
- dt_arena/mcp_server/atlassian/test_server.py +66 -0
- dt_arena/mcp_server/bigquery/main.py +333 -0
- dt_arena/mcp_server/booking/main.py +310 -0
- dt_arena/mcp_server/browser/main.py +1741 -0
- dt_arena/mcp_server/calendar/example_multi_user.py +162 -0
- dt_arena/mcp_server/calendar/main.py +792 -0
- dt_arena/mcp_server/calendar/test_mcp.py +135 -0
- dt_arena/mcp_server/customer_service/main.py +1063 -0
- dt_arena/mcp_server/databricks/main.py +566 -0
- dt_arena/mcp_server/databricks/probe.py +102 -0
- dt_arena/mcp_server/ers/main.py +845 -0
- dt_arena/mcp_server/finance/__init__.py +87 -0
- dt_arena/mcp_server/finance/core/__init__.py +12 -0
- dt_arena/mcp_server/finance/core/data_loader.py +558 -0
- dt_arena/mcp_server/finance/core/portfolio.py +565 -0
- dt_arena/mcp_server/finance/evaluation/__init__.py +20 -0
- dt_arena/mcp_server/finance/evaluation/evaluator.py +217 -0
- dt_arena/mcp_server/finance/evaluation/logger.py +137 -0
- dt_arena/mcp_server/finance/injection/__init__.py +66 -0
- dt_arena/mcp_server/finance/injection/config.py +176 -0
- dt_arena/mcp_server/finance/injection/content.py +755 -0
- dt_arena/mcp_server/finance/injection/html.py +409 -0
- dt_arena/mcp_server/finance/injection/locations.py +167 -0
- dt_arena/mcp_server/finance/injection/methods.py +193 -0
- dt_arena/mcp_server/finance/injection/presets.py +1023 -0
- dt_arena/mcp_server/finance/main.py +361 -0
- dt_arena/mcp_server/finance/run_mcp.py +21 -0
- dt_arena/mcp_server/finance/run_web.py +26 -0
- dt_arena/mcp_server/finance/server/__init__.py +41 -0
- dt_arena/mcp_server/finance/server/extractor.py +1453 -0
- dt_arena/mcp_server/finance/server/extractor_minimal.py +292 -0
- dt_arena/mcp_server/finance/server/extractor_simple.py +1164 -0
- dt_arena/mcp_server/finance/server/injection_mcp.py +865 -0
- dt_arena/mcp_server/finance/server/mcp.py +451 -0
- dt_arena/mcp_server/finance/server/tools/__init__.py +23 -0
- dt_arena/mcp_server/finance/server/tools/account.py +88 -0
- dt_arena/mcp_server/finance/server/tools/browsing.py +328 -0
- dt_arena/mcp_server/finance/server/tools/social.py +73 -0
- dt_arena/mcp_server/finance/server/tools/trading.py +242 -0
- dt_arena/mcp_server/finance/server/tools/utility.py +49 -0
- dt_arena/mcp_server/finance/server/web.py +2139 -0
- dt_arena/mcp_server/finance/tasks/benchmark/__init__.py +28 -0
- dt_arena/mcp_server/finance/tasks/benchmark/attack_pool.py +3026 -0
- dt_arena/mcp_server/finance/tasks/benchmark/attack_runner.py +1315 -0
- dt_arena/mcp_server/finance/tasks/benchmark/finra_requirements.py +1335 -0
- dt_arena/mcp_server/finance/tasks/benchmark/finra_tasks.py +3665 -0
- dt_arena/mcp_server/finance/tasks/benchmark/malicious_tasks.py +2673 -0
- dt_arena/mcp_server/finance/tasks/redteam_suite/run_redteam_suite.py +1713 -0
- dt_arena/mcp_server/finance/test_mcp_tools.py +476 -0
- dt_arena/mcp_server/github/main.py +441 -0
- dt_arena/mcp_server/gmail/main.py +1004 -0
- dt_arena/mcp_server/google_form/main.py +141 -0
- dt_arena/mcp_server/googledocs/main.py +458 -0
- dt_arena/mcp_server/hospital/mcp_server.py +458 -0
- dt_arena/mcp_server/legal/__init__.py +9 -0
- dt_arena/mcp_server/legal/core/__init__.py +14 -0
- dt_arena/mcp_server/legal/core/courtlistener_store.py +762 -0
- dt_arena/mcp_server/legal/core/data_loader.py +266 -0
- dt_arena/mcp_server/legal/core/document_store.py +197 -0
- dt_arena/mcp_server/legal/core/matter_manager.py +466 -0
- dt_arena/mcp_server/legal/main.py +89 -0
- dt_arena/mcp_server/legal/scripts/collect_data.py +988 -0
- dt_arena/mcp_server/legal/server/__init__.py +14 -0
- dt_arena/mcp_server/legal/server/mcp.py +2330 -0
- dt_arena/mcp_server/macos/client_test.py +270 -0
- dt_arena/mcp_server/macos/mcp_server.py +285 -0
- dt_arena/mcp_server/os-filesystem/main.py +1380 -0
- dt_arena/mcp_server/paypal/main.py +501 -0
- dt_arena/mcp_server/research/main.py +777 -0
- dt_arena/mcp_server/salesforce/main.py +2006 -0
- dt_arena/mcp_server/slack/main.py +318 -0
- dt_arena/mcp_server/snowflake/main.py +612 -0
- dt_arena/mcp_server/snowflake/probe.py +183 -0
- dt_arena/mcp_server/telecom/mcp_client.py +423 -0
- dt_arena/mcp_server/telecom/mcp_server.py +1059 -0
- dt_arena/mcp_server/telegram/main.py +338 -0
- dt_arena/mcp_server/terminal/main.py +163 -0
- dt_arena/mcp_server/travel/client_test.py +16 -0
- dt_arena/mcp_server/travel/mcp_server.py +404 -0
- dt_arena/mcp_server/whatsapp/main.py +318 -0
- dt_arena/mcp_server/windows/client_test.py +270 -0
- dt_arena/mcp_server/windows/mcp_server.py +218 -0
- dt_arena/mcp_server/zoom/main.py +466 -0
- dt_arena/src/__init__.py +0 -0
- dt_arena/src/hooks/__init__.py +0 -0
- dt_arena/src/hooks/audit_log.py +30 -0
- dt_arena/src/hooks/hooks.json +3 -0
- dt_arena/src/run_benign.py +142 -0
- dt_arena/src/types/__init__.py +0 -0
- dt_arena/src/types/agent.py +441 -0
- dt_arena/src/types/attacks.py +2 -0
- dt_arena/src/types/environment.py +2 -0
- dt_arena/src/types/hooks.py +174 -0
- dt_arena/src/types/judge.py +52 -0
- dt_arena/src/types/red_teaming_trajectory.py +385 -0
- dt_arena/src/types/task.py +260 -0
- dt_arena/src/types/trajectory.py +315 -0
- dt_arena/utils/__init__.py +1 -0
- dt_arena/utils/atlassian/__init__.py +27 -0
- dt_arena/utils/atlassian/helpers.py +520 -0
- dt_arena/utils/bigquery/__init__.py +1 -0
- dt_arena/utils/bigquery/helpers.py +246 -0
- dt_arena/utils/calendar/__init__.py +1 -0
- dt_arena/utils/calendar/helpers.py +87 -0
- dt_arena/utils/customer_service/__init__.py +17 -0
- dt_arena/utils/customer_service/cs_env_client.py +940 -0
- dt_arena/utils/customer_service/helpers.py +339 -0
- dt_arena/utils/customer_service/judges/__init__.py +20 -0
- dt_arena/utils/customer_service/judges/abusive_comms.py +149 -0
- dt_arena/utils/customer_service/judges/bulk_destructive.py +202 -0
- dt_arena/utils/customer_service/judges/cross_customer.py +481 -0
- dt_arena/utils/customer_service/judges/email_exfiltration.py +320 -0
- dt_arena/utils/customer_service/judges/financial_fraud.py +221 -0
- dt_arena/utils/customer_service/judges/llm_utils.py +264 -0
- dt_arena/utils/customer_service/judges/pii_disclosure.py +837 -0
- dt_arena/utils/customer_service/judges/policy_misrep.py +44 -0
- dt_arena/utils/customer_service/judges/text_utils.py +21 -0
- dt_arena/utils/databricks/__init__.py +2 -0
- dt_arena/utils/databricks/helpers.py +210 -0
- dt_arena/utils/finance/__init__.py +0 -0
- dt_arena/utils/finance/helpers.py +263 -0
- dt_arena/utils/github/__init__.py +1 -0
- dt_arena/utils/github/helpers.py +249 -0
- dt_arena/utils/gmail/__init__.py +1 -0
- dt_arena/utils/gmail/helpers.py +344 -0
- dt_arena/utils/google_form/__init__.py +2 -0
- dt_arena/utils/google_form/helpers.py +133 -0
- dt_arena/utils/legal/__init__.py +0 -0
- dt_arena/utils/legal/helpers.py +228 -0
- dt_arena/utils/macos/__init__.py +0 -0
- dt_arena/utils/macos/env_setup.py +215 -0
- dt_arena/utils/macos/helpers.py +61 -0
- dt_arena/utils/os_filesystem/__init__.py +1 -0
- dt_arena/utils/os_filesystem/helpers.py +366 -0
- dt_arena/utils/paypal/__init__.py +1 -0
- dt_arena/utils/paypal/helpers.py +178 -0
- dt_arena/utils/port_allocator.py +266 -0
- dt_arena/utils/research/__init__.py +0 -0
- dt_arena/utils/research/helpers.py +251 -0
- dt_arena/utils/salesforce/__init__.py +1 -0
- dt_arena/utils/salesforce/helpers.py +719 -0
- dt_arena/utils/slack/__init__.py +1 -0
- dt_arena/utils/slack/helpers.py +176 -0
- dt_arena/utils/snowflake/__init__.py +1 -0
- dt_arena/utils/snowflake/helpers.py +166 -0
- dt_arena/utils/telecom/__init__.py +1 -0
- dt_arena/utils/telecom/helpers.py +760 -0
- dt_arena/utils/telegram/__init__.py +0 -0
- dt_arena/utils/telegram/helpers.py +174 -0
- dt_arena/utils/terminal/__init__.py +0 -0
- dt_arena/utils/terminal/helpers.py +20 -0
- dt_arena/utils/travel/__init__.py +0 -0
- dt_arena/utils/travel/env_client.py +537 -0
- dt_arena/utils/travel/llm_judge.py +137 -0
- dt_arena/utils/travel/prompts.py +64 -0
- dt_arena/utils/utils/__init__.py +122 -0
- dt_arena/utils/whatsapp/__init__.py +0 -0
- dt_arena/utils/whatsapp/helpers.py +226 -0
- dt_arena/utils/windows/__init__.py +0 -0
- dt_arena/utils/windows/env_reset.py +224 -0
- dt_arena/utils/windows/env_setup.py +280 -0
- dt_arena/utils/windows/exfil_helpers.py +170 -0
- dt_arena/utils/windows/helpers.py +74 -0
- dt_arena/utils/zoom/__init__.py +1 -0
- dt_arena/utils/zoom/helpers.py +70 -0
- eval/__init__.py +1 -0
- eval/evaluation.py +426 -0
- eval/task_runner.py +449 -0
- utils/__init__.py +148 -0
- utils/agent_helpers.py +308 -0
- utils/agent_wrapper.py +189 -0
- utils/compose_utils.py +135 -0
- utils/config.py +77 -0
- utils/env_helpers.py +104 -0
- utils/eval_stats.py +88 -0
- utils/injection_helpers.py +429 -0
- utils/injection_mcp_helpers.py +152 -0
- utils/judge_helpers.py +181 -0
- utils/judge_utils.py +472 -0
- utils/llm.py +196 -0
- utils/logging.py +45 -0
- utils/mcp_helpers.py +232 -0
- utils/mcp_manager.py +235 -0
- utils/memory_guard.py +18 -0
- utils/red_teaming_sandbox.py +476 -0
- utils/reset_helpers.py +318 -0
- utils/resource_manager.py +370 -0
- utils/skill_helpers.py +447 -0
- utils/task_executor.py +904 -0
- utils/task_helpers.py +270 -0
- utils/template_helpers.py +179 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Gmail MCP server (sandboxed) backed by Mailpit.
|
|
4
|
+
Preserves tool signature: get_gmail_content(limit:int)->str
|
|
5
|
+
Also exposes list_messages, get_message, delete_all_messages, find_message for direct tooling.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
try:
|
|
17
|
+
import yaml # type: ignore
|
|
18
|
+
except Exception:
|
|
19
|
+
yaml = None
|
|
20
|
+
import smtplib
|
|
21
|
+
from email.message import EmailMessage
|
|
22
|
+
from email.utils import getaddresses
|
|
23
|
+
|
|
24
|
+
# Mailpit config
|
|
25
|
+
MAILPIT_BASE_URL = os.getenv("MAILPIT_BASE_URL", "http://localhost:8025")
|
|
26
|
+
# Use API Proxy for user-specific filtering
|
|
27
|
+
API_PROXY_URL = os.getenv("API_PROXY_URL", "http://localhost:8031")
|
|
28
|
+
MAILPIT_MESSAGES_API = f"{API_PROXY_URL}/api/v1/messages"
|
|
29
|
+
MAILPIT_MESSAGE_API = f"{API_PROXY_URL}/api/v1/message"
|
|
30
|
+
MAILPIT_SMTP_HOST = os.getenv("MAILPIT_SMTP_HOST", "localhost")
|
|
31
|
+
MAILPIT_SMTP_PORT = int(os.getenv("MAILPIT_SMTP_PORT", "1025"))
|
|
32
|
+
|
|
33
|
+
# User authentication
|
|
34
|
+
USER_ACCESS_TOKEN = os.getenv("USER_ACCESS_TOKEN", "tok_8Q85y4r9x30fS3kEgvSYGQaITM59VFKULz64lTI00Fc")
|
|
35
|
+
|
|
36
|
+
# Debug: Print token status on startup
|
|
37
|
+
import sys
|
|
38
|
+
print(f"[MCP Server] ===== STARTING =====", file=sys.stderr)
|
|
39
|
+
print(f"[MCP Server] USER_ACCESS_TOKEN: {USER_ACCESS_TOKEN[:20] if USER_ACCESS_TOKEN else 'NONE'}...", file=sys.stderr)
|
|
40
|
+
print(f"[MCP Server] API_PROXY_URL: {API_PROXY_URL}", file=sys.stderr)
|
|
41
|
+
print(f"[MCP Server] AUTH_API_URL: {os.getenv('AUTH_API_URL', 'NOT SET')}", file=sys.stderr)
|
|
42
|
+
print(f"[MCP Server] MAILPIT_SMTP_HOST: {MAILPIT_SMTP_HOST}", file=sys.stderr)
|
|
43
|
+
print(f"[MCP Server] ==================", file=sys.stderr)
|
|
44
|
+
sys.stderr.flush()
|
|
45
|
+
|
|
46
|
+
# Create a FastMCP server
|
|
47
|
+
mcp = FastMCP("Gmail Client")
|
|
48
|
+
|
|
49
|
+
_http_client: Optional[httpx.AsyncClient] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_auth_headers() -> Dict[str, str]:
|
|
53
|
+
"""Get authentication headers with access token from env."""
|
|
54
|
+
headers = {
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
"Accept-Encoding": "identity" # Disable compression to avoid decoding errors
|
|
57
|
+
}
|
|
58
|
+
if USER_ACCESS_TOKEN:
|
|
59
|
+
headers["Authorization"] = f"Bearer {USER_ACCESS_TOKEN}"
|
|
60
|
+
return headers
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def get_http() -> httpx.AsyncClient:
|
|
64
|
+
"""Get HTTP client with access token from env."""
|
|
65
|
+
global _http_client
|
|
66
|
+
|
|
67
|
+
if _http_client is None:
|
|
68
|
+
_http_client = httpx.AsyncClient(
|
|
69
|
+
timeout=20.0,
|
|
70
|
+
headers=_get_auth_headers()
|
|
71
|
+
)
|
|
72
|
+
return _http_client
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _get_current_user_email() -> Optional[str]:
|
|
77
|
+
"""Get the email address of the current authenticated user.
|
|
78
|
+
|
|
79
|
+
Retries up to 10 times to tolerate auth service cold start.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
if not USER_ACCESS_TOKEN:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
import asyncio
|
|
86
|
+
auth_url = os.getenv("AUTH_API_URL", "http://localhost:8030")
|
|
87
|
+
|
|
88
|
+
for attempt in range(1, 11):
|
|
89
|
+
try:
|
|
90
|
+
client = await get_http()
|
|
91
|
+
resp = await client.get(
|
|
92
|
+
f"{auth_url}/api/v1/auth/me",
|
|
93
|
+
headers={"Authorization": f"Bearer {USER_ACCESS_TOKEN}"},
|
|
94
|
+
timeout=3.0,
|
|
95
|
+
)
|
|
96
|
+
if resp.status_code == 200:
|
|
97
|
+
user_data = resp.json()
|
|
98
|
+
email = user_data.get("email")
|
|
99
|
+
if email:
|
|
100
|
+
return email
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
await asyncio.sleep(min(0.3 * attempt, 2.0))
|
|
104
|
+
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_msg_id(m: Dict[str, Any]) -> str:
|
|
109
|
+
return str(m.get("ID") or m.get("Id") or m.get("id") or "")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _safe_lower(value: Optional[str]) -> str:
|
|
113
|
+
return (value or "").strip().lower()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _pick(d: Dict[str, Any], *keys: str) -> Optional[str]:
|
|
117
|
+
for k in keys:
|
|
118
|
+
if k in d and d[k]:
|
|
119
|
+
return d[k]
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_datetime(value: Optional[str]) -> str:
|
|
124
|
+
if not value:
|
|
125
|
+
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
126
|
+
try:
|
|
127
|
+
if value.isdigit():
|
|
128
|
+
ts = int(value)
|
|
129
|
+
if ts > 10_000_000_000:
|
|
130
|
+
ts //= 1000
|
|
131
|
+
return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
try:
|
|
135
|
+
from email.utils import parsedate_to_datetime
|
|
136
|
+
return parsedate_to_datetime(value).strftime('%Y-%m-%d %H:%M:%S')
|
|
137
|
+
except Exception:
|
|
138
|
+
try:
|
|
139
|
+
return datetime.fromisoformat(value).strftime('%Y-%m-%d %H:%M:%S')
|
|
140
|
+
except Exception:
|
|
141
|
+
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _fetch_messages(limit_scan: int) -> List[Dict[str, Any]]:
|
|
145
|
+
client = await get_http()
|
|
146
|
+
# Request more messages to ensure we find inbox messages among sent ones
|
|
147
|
+
api_limit = max(200, limit_scan * 10)
|
|
148
|
+
resp = await client.get(
|
|
149
|
+
f"{MAILPIT_MESSAGES_API}?limit={api_limit}",
|
|
150
|
+
headers=_get_auth_headers()
|
|
151
|
+
)
|
|
152
|
+
resp.raise_for_status()
|
|
153
|
+
data = resp.json()
|
|
154
|
+
messages: List[Dict[str, Any]]
|
|
155
|
+
if isinstance(data, dict) and isinstance(data.get("messages"), list):
|
|
156
|
+
messages = data["messages"]
|
|
157
|
+
elif isinstance(data, list):
|
|
158
|
+
messages = data
|
|
159
|
+
else:
|
|
160
|
+
messages = []
|
|
161
|
+
return messages
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _fetch_message_detail(msg_id: str) -> Dict[str, Any]:
|
|
165
|
+
client = await get_http()
|
|
166
|
+
resp = await client.get(
|
|
167
|
+
f"{MAILPIT_MESSAGE_API}/{msg_id}",
|
|
168
|
+
headers=_get_auth_headers()
|
|
169
|
+
)
|
|
170
|
+
if resp.status_code == 404:
|
|
171
|
+
return {}
|
|
172
|
+
resp.raise_for_status()
|
|
173
|
+
return resp.json()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _extract_headers(detail: Dict[str, Any]) -> Dict[str, str]:
|
|
177
|
+
headers: Dict[str, str] = {}
|
|
178
|
+
raw_headers = detail.get("Headers") or detail.get("headers") or {}
|
|
179
|
+
if isinstance(raw_headers, dict):
|
|
180
|
+
for k, v in raw_headers.items():
|
|
181
|
+
if isinstance(v, list) and v:
|
|
182
|
+
headers[k] = v[0]
|
|
183
|
+
elif isinstance(v, str):
|
|
184
|
+
headers[k] = v
|
|
185
|
+
if not headers.get("Subject") and detail.get("Subject"):
|
|
186
|
+
headers["Subject"] = detail.get("Subject")
|
|
187
|
+
if not headers.get("From") and detail.get("From"):
|
|
188
|
+
headers["From"] = detail.get("From")
|
|
189
|
+
if not headers.get("To") and detail.get("To"):
|
|
190
|
+
headers["To"] = detail.get("To")
|
|
191
|
+
if not headers.get("Date") and detail.get("Date"):
|
|
192
|
+
headers["Date"] = detail.get("Date")
|
|
193
|
+
return headers
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _extract_body(detail: Dict[str, Any]) -> str:
|
|
197
|
+
text = _pick(detail, "Text", "text")
|
|
198
|
+
if isinstance(text, str) and text.strip():
|
|
199
|
+
return text
|
|
200
|
+
html = _pick(detail, "HTML", "html")
|
|
201
|
+
if isinstance(html, str) and html.strip():
|
|
202
|
+
try:
|
|
203
|
+
import re
|
|
204
|
+
return re.sub(r"<[^>]+>", "", html)
|
|
205
|
+
except Exception:
|
|
206
|
+
return html
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _make_thread_id(subject: str) -> str:
|
|
211
|
+
import hashlib
|
|
212
|
+
s = (subject or "").strip().lower().encode("utf-8")
|
|
213
|
+
return f"subj-{hashlib.sha1(s).hexdigest()[:16]}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _as_text(value: Any) -> str:
|
|
217
|
+
if value is None:
|
|
218
|
+
return ""
|
|
219
|
+
if isinstance(value, list):
|
|
220
|
+
return " ".join(str(x) for x in value)
|
|
221
|
+
return str(value)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _addresses_from_field(value: Any) -> List[str]:
|
|
225
|
+
raw_parts: List[str] = []
|
|
226
|
+
extracted: List[str] = []
|
|
227
|
+
|
|
228
|
+
def add_from_item(item: Any) -> None:
|
|
229
|
+
if item is None:
|
|
230
|
+
return
|
|
231
|
+
if isinstance(item, dict):
|
|
232
|
+
found = False
|
|
233
|
+
for key in ("Address", "address", "Email", "email"):
|
|
234
|
+
if key in item and item[key]:
|
|
235
|
+
extracted.append(str(item[key]))
|
|
236
|
+
found = True
|
|
237
|
+
break
|
|
238
|
+
if not found:
|
|
239
|
+
raw_parts.append(str(item))
|
|
240
|
+
else:
|
|
241
|
+
raw_parts.append(str(item))
|
|
242
|
+
|
|
243
|
+
if isinstance(value, list):
|
|
244
|
+
for it in value:
|
|
245
|
+
add_from_item(it)
|
|
246
|
+
elif isinstance(value, dict):
|
|
247
|
+
add_from_item(value)
|
|
248
|
+
elif value is not None:
|
|
249
|
+
raw_parts.append(str(value))
|
|
250
|
+
|
|
251
|
+
extracted += [addr for _, addr in getaddresses(raw_parts) if addr]
|
|
252
|
+
return [a.lower() for a in extracted if a]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def _try_delete_variants(client: httpx.AsyncClient, msg_id: str) -> httpx.Response | None:
|
|
256
|
+
"""Try multiple endpoint patterns for single delete across Mailpit variants."""
|
|
257
|
+
candidates: List[Tuple[str, str, Dict[str, str] | None, Any]] = [
|
|
258
|
+
# Put the confirmed-working endpoint first
|
|
259
|
+
("DELETE", f"{MAILPIT_MESSAGES_API}?id={msg_id}", None, None),
|
|
260
|
+
# Other possible variants
|
|
261
|
+
("DELETE", f"{MAILPIT_MESSAGE_API}?id={msg_id}", None, None),
|
|
262
|
+
("DELETE", f"{MAILPIT_MESSAGE_API}/{msg_id}", None, None),
|
|
263
|
+
("DELETE", f"{MAILPIT_MESSAGES_API}/{msg_id}", None, None),
|
|
264
|
+
# Method override via POST
|
|
265
|
+
("POST", f"{MAILPIT_MESSAGE_API}/{msg_id}", {"X-HTTP-Method-Override": "DELETE"}, None),
|
|
266
|
+
("POST", f"{MAILPIT_MESSAGES_API}/{msg_id}", {"X-HTTP-Method-Override": "DELETE"}, None),
|
|
267
|
+
# Bulk-style JSON payload with a single id (in case supported)
|
|
268
|
+
("POST", f"{MAILPIT_MESSAGES_API}", {"Content-Type": "application/json"}, json.dumps({"ids": [msg_id]})),
|
|
269
|
+
]
|
|
270
|
+
last: Optional[httpx.Response] = None
|
|
271
|
+
for method, url, headers, data in candidates:
|
|
272
|
+
try:
|
|
273
|
+
if method == "DELETE":
|
|
274
|
+
r = await client.request("DELETE", url, headers=headers)
|
|
275
|
+
else:
|
|
276
|
+
r = await client.request(method, url, headers=headers, content=data)
|
|
277
|
+
if r.status_code in (200, 204):
|
|
278
|
+
return r
|
|
279
|
+
last = r
|
|
280
|
+
except Exception:
|
|
281
|
+
continue
|
|
282
|
+
return last
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def _delete_by_id_strict(client: httpx.AsyncClient, msg_id: str) -> httpx.Response | None:
|
|
286
|
+
"""Delete a single message using Mailpit's batch delete API with IDs array.
|
|
287
|
+
|
|
288
|
+
CRITICAL: Mailpit API behavior:
|
|
289
|
+
- DELETE /api/v1/messages with body {"IDs": ["id1", "id2"]} → deletes specified messages
|
|
290
|
+
- DELETE /api/v1/messages with NO body → deletes ALL messages!
|
|
291
|
+
|
|
292
|
+
We MUST ensure the JSON body is always sent with proper Content-Type header.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
if not msg_id or not msg_id.strip():
|
|
296
|
+
raise ValueError("msg_id cannot be empty")
|
|
297
|
+
|
|
298
|
+
# Mailpit requires: DELETE /api/v1/messages with body {"IDs": ["id"]}
|
|
299
|
+
# Note: uppercase "IDs" is required!
|
|
300
|
+
payload = {"IDs": [msg_id.strip()]}
|
|
301
|
+
|
|
302
|
+
# httpx.delete() doesn't support json parameter, use request() instead
|
|
303
|
+
headers = {
|
|
304
|
+
"Content-Type": "application/json",
|
|
305
|
+
"Accept-Encoding": "identity",
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
r = await client.request(
|
|
309
|
+
"DELETE",
|
|
310
|
+
MAILPIT_MESSAGES_API,
|
|
311
|
+
content=json.dumps(payload),
|
|
312
|
+
headers=headers
|
|
313
|
+
)
|
|
314
|
+
return r
|
|
315
|
+
except Exception as e:
|
|
316
|
+
# Return a mock response with error
|
|
317
|
+
class ErrorResponse:
|
|
318
|
+
status_code = 500
|
|
319
|
+
text = str(e)
|
|
320
|
+
return ErrorResponse()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@mcp.tool()
|
|
324
|
+
async def get_gmail_content(limit: int = 20) -> str:
|
|
325
|
+
"""Return simplified Gmail threads from recent inbox messages.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
limit: Number of recent threads to include (default 20, max 200)
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
JSON object {"threads": [...]} with id/from/to/subject/snippet/created fields.
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
threads = await _build_threads(limit_threads=max(1, min(200, limit)))
|
|
335
|
+
result = {"threads": threads}
|
|
336
|
+
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
337
|
+
except httpx.HTTPError as e:
|
|
338
|
+
return json.dumps({"error": f"Mailpit HTTP error: {e}"}, ensure_ascii=False)
|
|
339
|
+
except Exception as e: # pragma: no cover
|
|
340
|
+
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# Lightweight thread builder: list recent messages and group by subject
|
|
344
|
+
async def _build_threads(limit_threads: int = 20) -> list[dict]:
|
|
345
|
+
"""
|
|
346
|
+
Build a simple list of 'threads' from recent messages.
|
|
347
|
+
We approximate threads by grouping messages with the same subject.
|
|
348
|
+
"""
|
|
349
|
+
client = await get_http()
|
|
350
|
+
resp = await client.get(f"{MAILPIT_MESSAGES_API}?limit={max(1, min(500, limit_threads))}")
|
|
351
|
+
if resp.status_code != 200:
|
|
352
|
+
return [{"error": f"HTTP {resp.status_code}: {resp.text}"}]
|
|
353
|
+
data = resp.json() or {}
|
|
354
|
+
messages = data.get("messages") or []
|
|
355
|
+
# Normalize minimal fields
|
|
356
|
+
out = []
|
|
357
|
+
for m in messages:
|
|
358
|
+
out.append({
|
|
359
|
+
"id": m.get("ID") or m.get("id"),
|
|
360
|
+
"from": (m.get("From") or {}).get("Address") if isinstance(m.get("From"), dict) else m.get("From"),
|
|
361
|
+
"to": m.get("To"),
|
|
362
|
+
"subject": m.get("Subject"),
|
|
363
|
+
"snippet": m.get("Snippet"),
|
|
364
|
+
"created": m.get("Created"),
|
|
365
|
+
})
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@mcp.tool()
|
|
370
|
+
async def list_messages(limit: int = 50) -> str:
|
|
371
|
+
"""List received emails (inbox) - messages where you are a recipient (To or Cc).
|
|
372
|
+
Use this to view, browse, or list your inbox emails. This is a READ-ONLY operation.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
limit: Maximum number of messages to return (default: 50, max: 500)
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
JSON array of inbox messages (emails received by the authenticated user)
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
user_email = await _get_current_user_email()
|
|
382
|
+
if not user_email:
|
|
383
|
+
return json.dumps({"error": "Authentication required to list inbox"})
|
|
384
|
+
|
|
385
|
+
user_email_lower = user_email.lower()
|
|
386
|
+
msgs = await _fetch_messages(limit_scan=max(1, min(500, limit * 2)))
|
|
387
|
+
|
|
388
|
+
inbox_msgs = []
|
|
389
|
+
for m in msgs:
|
|
390
|
+
if len(inbox_msgs) >= limit:
|
|
391
|
+
break
|
|
392
|
+
# Check if user is in To or Cc
|
|
393
|
+
to_addrs = _addresses_from_field(m.get("To") or m.get("to"))
|
|
394
|
+
cc_addrs = _addresses_from_field(m.get("Cc") or m.get("cc"))
|
|
395
|
+
all_recipients = [a.lower() for a in to_addrs + cc_addrs]
|
|
396
|
+
if user_email_lower in all_recipients:
|
|
397
|
+
inbox_msgs.append(m)
|
|
398
|
+
|
|
399
|
+
return json.dumps(inbox_msgs, ensure_ascii=False)
|
|
400
|
+
except Exception as e:
|
|
401
|
+
return json.dumps({"error": str(e)})
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@mcp.tool()
|
|
405
|
+
async def list_sent_messages(limit: int = 50) -> str:
|
|
406
|
+
"""List sent emails - messages where you are the sender (From).
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
limit: Maximum number of messages to return (default: 50, max: 500)
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
JSON array of sent messages (emails sent by the authenticated user)
|
|
413
|
+
"""
|
|
414
|
+
try:
|
|
415
|
+
user_email = await _get_current_user_email()
|
|
416
|
+
if not user_email:
|
|
417
|
+
return json.dumps({"error": "Authentication required to list sent messages"})
|
|
418
|
+
|
|
419
|
+
user_email_lower = user_email.lower()
|
|
420
|
+
msgs = await _fetch_messages(limit_scan=max(1, min(500, limit * 2)))
|
|
421
|
+
|
|
422
|
+
sent_msgs = []
|
|
423
|
+
for m in msgs:
|
|
424
|
+
if len(sent_msgs) >= limit:
|
|
425
|
+
break
|
|
426
|
+
# Check if user is the sender
|
|
427
|
+
from_addrs = _addresses_from_field(m.get("From") or m.get("from"))
|
|
428
|
+
if user_email_lower in [a.lower() for a in from_addrs]:
|
|
429
|
+
sent_msgs.append(m)
|
|
430
|
+
|
|
431
|
+
return json.dumps(sent_msgs, ensure_ascii=False)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
return json.dumps({"error": str(e)})
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@mcp.tool()
|
|
437
|
+
async def get_message(id: str) -> str: # noqa: A002 - keep signature
|
|
438
|
+
"""Get full details of a specific email by its ID.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
id: The message ID (obtained from list_messages, search_messages, or find_message)
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
JSON object with complete message details including headers, body, attachments, etc.
|
|
445
|
+
"""
|
|
446
|
+
if not id:
|
|
447
|
+
return json.dumps({"error": "id is required"})
|
|
448
|
+
try:
|
|
449
|
+
detail = await _fetch_message_detail(id)
|
|
450
|
+
if not detail:
|
|
451
|
+
return json.dumps({"error": f"Message {id} not found"})
|
|
452
|
+
return json.dumps(detail, ensure_ascii=False)
|
|
453
|
+
except Exception as e:
|
|
454
|
+
return json.dumps({"error": str(e)})
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@mcp.tool()
|
|
458
|
+
async def delete_all_messages() -> str:
|
|
459
|
+
"""[DANGEROUS] Permanently delete ALL emails in Mailpit.
|
|
460
|
+
Only use when explicitly asked to 'delete all' or 'clear all emails'.
|
|
461
|
+
DO NOT use for listing, viewing, or searching emails."""
|
|
462
|
+
try:
|
|
463
|
+
client = await get_http()
|
|
464
|
+
r = await client.delete(MAILPIT_MESSAGES_API)
|
|
465
|
+
if r.status_code not in (200, 204):
|
|
466
|
+
return json.dumps({"error": r.text, "status": r.status_code})
|
|
467
|
+
return json.dumps({"ok": True})
|
|
468
|
+
except Exception as e:
|
|
469
|
+
return json.dumps({"error": str(e)})
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@mcp.tool()
|
|
473
|
+
async def delete_message(id: str) -> str: # noqa: A002 - keep signature
|
|
474
|
+
"""Delete a single email by id (idempotent).
|
|
475
|
+
|
|
476
|
+
Warning: Uses Mailpit batch-delete endpoint correctly to avoid deleting all messages.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
id: Message id (REQUIRED)
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
JSON {"ok": true, "id": id, "status": code} or error.
|
|
483
|
+
"""
|
|
484
|
+
if not id:
|
|
485
|
+
return json.dumps({"error": "id is required"})
|
|
486
|
+
try:
|
|
487
|
+
client = await get_http()
|
|
488
|
+
r = await _delete_by_id_strict(client, id)
|
|
489
|
+
status = getattr(r, "status_code", 0)
|
|
490
|
+
if status in (200, 204, 404): # idempotent
|
|
491
|
+
return json.dumps({"ok": True, "id": id, "status": status})
|
|
492
|
+
return json.dumps({
|
|
493
|
+
"error": getattr(r, "text", "Delete failed"),
|
|
494
|
+
"status": status,
|
|
495
|
+
"id": id,
|
|
496
|
+
})
|
|
497
|
+
except Exception as e:
|
|
498
|
+
return json.dumps({"error": str(e)})
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@mcp.tool()
|
|
502
|
+
async def find_message(subject_contains: Optional[str] = None,
|
|
503
|
+
from_contains: Optional[str] = None,
|
|
504
|
+
to_contains: Optional[str] = None,
|
|
505
|
+
limit: int = 100) -> str:
|
|
506
|
+
"""Find the FIRST email matching the given criteria.
|
|
507
|
+
Returns a single message object or an error if not found.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
subject_contains: Text to search in subject (case-insensitive)
|
|
511
|
+
from_contains: Text to search in sender address or name (case-insensitive)
|
|
512
|
+
to_contains: Text to search in recipient address or name (case-insensitive)
|
|
513
|
+
limit: Number of recent emails to scan through (default: 100, max: 1000).
|
|
514
|
+
This controls how many emails to check, NOT how many to return.
|
|
515
|
+
Increase this if the target email is older.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
JSON string containing the first matching message, or {"error": "No matching message found"}
|
|
519
|
+
|
|
520
|
+
Note: This function always returns at most ONE message. If you need multiple results, use search_messages instead.
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
msgs = await _fetch_messages(limit_scan=max(1, min(1000, limit)))
|
|
524
|
+
subj_q = (subject_contains or "").lower()
|
|
525
|
+
from_q = (from_contains or "").lower()
|
|
526
|
+
to_q = (to_contains or "").lower()
|
|
527
|
+
for m in msgs:
|
|
528
|
+
subj = _as_text(m.get("Subject") or m.get("subject")).lower()
|
|
529
|
+
from_text = _as_text(m.get("From") or m.get("from"))
|
|
530
|
+
to_text = _as_text(m.get("To") or m.get("to"))
|
|
531
|
+
from_emails = " ".join(_addresses_from_field(m.get("From") or m.get("from")))
|
|
532
|
+
to_emails = " ".join(_addresses_from_field(m.get("To") or m.get("to")))
|
|
533
|
+
|
|
534
|
+
ok = True
|
|
535
|
+
if subj_q:
|
|
536
|
+
ok = ok and subj_q in subj
|
|
537
|
+
if from_q:
|
|
538
|
+
ok = ok and (from_q in from_emails.lower() or from_q in from_text.lower())
|
|
539
|
+
if to_q:
|
|
540
|
+
ok = ok and (to_q in to_emails.lower() or to_q in to_text.lower())
|
|
541
|
+
if ok:
|
|
542
|
+
return json.dumps(m, ensure_ascii=False)
|
|
543
|
+
|
|
544
|
+
for m in msgs:
|
|
545
|
+
mid = str(m.get("ID") or m.get("Id") or m.get("id") or "")
|
|
546
|
+
if not mid:
|
|
547
|
+
continue
|
|
548
|
+
detail = await _fetch_message_detail(mid)
|
|
549
|
+
if not detail:
|
|
550
|
+
continue
|
|
551
|
+
headers = _extract_headers(detail)
|
|
552
|
+
subj = (headers.get("Subject") or "").lower()
|
|
553
|
+
|
|
554
|
+
hdr_from_emails = _addresses_from_field(headers.get("From"))
|
|
555
|
+
hdr_to_emails = _addresses_from_field(headers.get("To"))
|
|
556
|
+
root_from_emails = _addresses_from_field(detail.get("From"))
|
|
557
|
+
root_to_emails = _addresses_from_field(detail.get("To"))
|
|
558
|
+
|
|
559
|
+
all_from = set([e.lower() for e in hdr_from_emails + root_from_emails])
|
|
560
|
+
all_to = set([e.lower() for e in hdr_to_emails + root_to_emails])
|
|
561
|
+
|
|
562
|
+
ok = True
|
|
563
|
+
if subj_q:
|
|
564
|
+
ok = ok and subj_q in subj
|
|
565
|
+
if from_q:
|
|
566
|
+
ok = ok and any(from_q in e for e in all_from)
|
|
567
|
+
if to_q:
|
|
568
|
+
ok = ok and any(to_q in e for e in all_to)
|
|
569
|
+
if ok:
|
|
570
|
+
return json.dumps(detail, ensure_ascii=False)
|
|
571
|
+
|
|
572
|
+
return json.dumps({"error": "No matching message found"})
|
|
573
|
+
except Exception as e:
|
|
574
|
+
return json.dumps({"error": str(e)})
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@mcp.tool()
|
|
578
|
+
async def search_messages(subject_contains: Optional[str] = None,
|
|
579
|
+
from_contains: Optional[str] = None,
|
|
580
|
+
to_contains: Optional[str] = None,
|
|
581
|
+
body_contains: Optional[str] = None,
|
|
582
|
+
has_attachment: Optional[bool] = None,
|
|
583
|
+
limit: int = 50) -> str:
|
|
584
|
+
"""Search for emails matching the given criteria and return ALL matching results (up to limit).
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
subject_contains: Text to search in subject (case-insensitive)
|
|
588
|
+
from_contains: Text to search in sender address or name (case-insensitive)
|
|
589
|
+
to_contains: Text to search in recipient address or name (case-insensitive)
|
|
590
|
+
body_contains: Text to search in email body (case-insensitive, slower as it fetches full content)
|
|
591
|
+
has_attachment: Filter by attachment presence (True/False)
|
|
592
|
+
limit: Maximum number of matching results to return (default: 50, max: 200).
|
|
593
|
+
This controls how many matching emails to return in the results.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
JSON array of matching messages (may be empty if no matches found)
|
|
597
|
+
|
|
598
|
+
Note: This function scans up to 1000 recent emails and returns multiple matches.
|
|
599
|
+
Use find_message if you only need the first match.
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
msgs = await _fetch_messages(limit_scan=1000)
|
|
603
|
+
results: List[Dict[str, Any]] = []
|
|
604
|
+
subj_q = (subject_contains or "").lower()
|
|
605
|
+
from_q = (from_contains or "").lower()
|
|
606
|
+
to_q = (to_contains or "").lower()
|
|
607
|
+
body_q = (body_contains or "").lower()
|
|
608
|
+
for m in msgs:
|
|
609
|
+
if len(results) >= max(1, min(200, limit)):
|
|
610
|
+
break
|
|
611
|
+
subj = _as_text(m.get("Subject") or m.get("subject")).lower()
|
|
612
|
+
from_text = _as_text(m.get("From") or m.get("from"))
|
|
613
|
+
to_text = _as_text(m.get("To") or m.get("to"))
|
|
614
|
+
att_count = m.get("Attachments") or m.get("attachments")
|
|
615
|
+
ok = True
|
|
616
|
+
if subj_q:
|
|
617
|
+
ok = ok and subj_q in subj
|
|
618
|
+
if from_q:
|
|
619
|
+
ok = ok and from_q in from_text.lower()
|
|
620
|
+
if to_q:
|
|
621
|
+
ok = ok and to_q in to_text.lower()
|
|
622
|
+
if has_attachment is not None:
|
|
623
|
+
try:
|
|
624
|
+
ok = ok and bool(att_count) == bool(has_attachment)
|
|
625
|
+
except Exception:
|
|
626
|
+
ok = False
|
|
627
|
+
if not ok:
|
|
628
|
+
continue
|
|
629
|
+
if body_q:
|
|
630
|
+
mid = str(m.get("ID") or m.get("Id") or m.get("id") or "")
|
|
631
|
+
if not mid:
|
|
632
|
+
continue
|
|
633
|
+
detail = await _fetch_message_detail(mid)
|
|
634
|
+
body_text = _extract_body(detail).lower()
|
|
635
|
+
if body_q not in body_text:
|
|
636
|
+
continue
|
|
637
|
+
results.append(m)
|
|
638
|
+
return json.dumps(results[: max(1, min(200, limit))], ensure_ascii=False)
|
|
639
|
+
except Exception as e:
|
|
640
|
+
return json.dumps({"error": str(e)})
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@mcp.tool()
|
|
644
|
+
async def get_message_body(id: str, prefer: Optional[str] = "auto") -> str:
|
|
645
|
+
"""Get the body content of a specific email.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
id: The message ID
|
|
649
|
+
prefer: Content format preference - "text" (plain text only), "html" (HTML only),
|
|
650
|
+
or "auto" (both formats, default)
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
JSON object with "text" and/or "html" fields containing the email body
|
|
654
|
+
"""
|
|
655
|
+
if not id:
|
|
656
|
+
return json.dumps({"error": "id is required"})
|
|
657
|
+
try:
|
|
658
|
+
detail = await _fetch_message_detail(id)
|
|
659
|
+
text = _pick(detail, "Text", "text") or ""
|
|
660
|
+
html = _pick(detail, "HTML", "html") or ""
|
|
661
|
+
if prefer == "text":
|
|
662
|
+
data = {"text": text}
|
|
663
|
+
elif prefer == "html":
|
|
664
|
+
data = {"html": html}
|
|
665
|
+
else:
|
|
666
|
+
data = {"text": text, "html": html}
|
|
667
|
+
return json.dumps(data, ensure_ascii=False)
|
|
668
|
+
except Exception as e:
|
|
669
|
+
return json.dumps({"error": str(e)})
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@mcp.tool()
|
|
673
|
+
async def list_attachments(id: str) -> str:
|
|
674
|
+
"""List all attachments in a specific email.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
id: The message ID
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
JSON array of attachment objects with filename, content type, and size information
|
|
681
|
+
"""
|
|
682
|
+
if not id:
|
|
683
|
+
return json.dumps({"error": "id is required"})
|
|
684
|
+
try:
|
|
685
|
+
detail = await _fetch_message_detail(id)
|
|
686
|
+
atts = _extract_attachments(detail)
|
|
687
|
+
return json.dumps(atts, ensure_ascii=False)
|
|
688
|
+
except Exception as e:
|
|
689
|
+
return json.dumps({"error": str(e)})
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@mcp.tool()
|
|
693
|
+
async def send_reply(id: str,
|
|
694
|
+
body: Optional[str] = None,
|
|
695
|
+
subject_prefix: Optional[str] = "Re:",
|
|
696
|
+
from_email: Optional[str] = None,
|
|
697
|
+
cc: Optional[str] = None,
|
|
698
|
+
bcc: Optional[str] = None) -> str:
|
|
699
|
+
"""Reply to a specific email. The original message body will be included below your reply.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
id: The message ID to reply to (REQUIRED - use search_messages or find_message to get this)
|
|
703
|
+
body: Your reply message content
|
|
704
|
+
subject_prefix: Prefix for reply subject (default: "Re:")
|
|
705
|
+
from_email: Sender address (must match your authenticated email, auto-filled if not provided)
|
|
706
|
+
cc: CC recipients (comma-separated)
|
|
707
|
+
bcc: BCC recipients (comma-separated)
|
|
708
|
+
|
|
709
|
+
Returns:
|
|
710
|
+
JSON object with send status
|
|
711
|
+
|
|
712
|
+
Security: You can only send from your own authenticated email address.
|
|
713
|
+
"""
|
|
714
|
+
if not id:
|
|
715
|
+
return json.dumps({"error": "id is required"})
|
|
716
|
+
try:
|
|
717
|
+
detail = await _fetch_message_detail(id)
|
|
718
|
+
headers = _extract_headers(detail)
|
|
719
|
+
to_addr = headers.get("From") or ""
|
|
720
|
+
to_list = _addresses_from_field(to_addr)
|
|
721
|
+
if not to_list:
|
|
722
|
+
return json.dumps({"error": "original sender not found"})
|
|
723
|
+
subject = headers.get("Subject") or ""
|
|
724
|
+
reply_subj = f"{subject_prefix.strip()} {subject}".strip()
|
|
725
|
+
original = _extract_body(detail)
|
|
726
|
+
reply_body = (body or "").strip() + ("\n\n" + original if original else "")
|
|
727
|
+
|
|
728
|
+
# Verify sender identity
|
|
729
|
+
user_email = await _get_current_user_email()
|
|
730
|
+
if user_email:
|
|
731
|
+
if from_email and from_email.lower().strip() != user_email.lower().strip():
|
|
732
|
+
return json.dumps({
|
|
733
|
+
"error": f"Permission denied: You can only send from your own email address ({user_email}), but attempted to send from {from_email}"
|
|
734
|
+
})
|
|
735
|
+
if not from_email:
|
|
736
|
+
from_email = user_email
|
|
737
|
+
elif from_email:
|
|
738
|
+
return json.dumps({
|
|
739
|
+
"error": "Authentication required: You must be authenticated to send emails"
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
result = await asyncio.to_thread(
|
|
743
|
+
_send_email_sync, to_list[0], reply_subj, reply_body, from_email, cc, bcc
|
|
744
|
+
)
|
|
745
|
+
return json.dumps(result, ensure_ascii=False)
|
|
746
|
+
except Exception as e:
|
|
747
|
+
return json.dumps({"error": str(e)})
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@mcp.tool()
|
|
751
|
+
async def forward_message(id: str,
|
|
752
|
+
to: str,
|
|
753
|
+
subject_prefix: Optional[str] = "Fwd:",
|
|
754
|
+
from_email: Optional[str] = None) -> str:
|
|
755
|
+
"""Forward a specific email to another recipient. Original headers and body will be included.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
id: The message ID to forward (REQUIRED)
|
|
759
|
+
to: Recipient email address (REQUIRED)
|
|
760
|
+
subject_prefix: Prefix for forwarded subject (default: "Fwd:")
|
|
761
|
+
from_email: Sender address (must match your authenticated email, auto-filled if not provided)
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
JSON object with send status
|
|
765
|
+
|
|
766
|
+
Security: You can only send from your own authenticated email address.
|
|
767
|
+
"""
|
|
768
|
+
if not id or not to:
|
|
769
|
+
return json.dumps({"error": "id and to are required"})
|
|
770
|
+
try:
|
|
771
|
+
detail = await _fetch_message_detail(id)
|
|
772
|
+
headers = _extract_headers(detail)
|
|
773
|
+
subject = headers.get("Subject") or ""
|
|
774
|
+
fwd_subj = f"{subject_prefix.strip()} {subject}".strip()
|
|
775
|
+
hdrs = [f"{k}: {v}" for k, v in headers.items() if k in ("From", "To", "Date", "Subject")]
|
|
776
|
+
original = _extract_body(detail)
|
|
777
|
+
body = "\n".join(hdrs) + ("\n\n" + original if original else "")
|
|
778
|
+
|
|
779
|
+
# Verify sender identity
|
|
780
|
+
user_email = await _get_current_user_email()
|
|
781
|
+
if user_email:
|
|
782
|
+
if from_email and from_email.lower().strip() != user_email.lower().strip():
|
|
783
|
+
return json.dumps({
|
|
784
|
+
"error": f"Permission denied: You can only send from your own email address ({user_email}), but attempted to send from {from_email}"
|
|
785
|
+
})
|
|
786
|
+
if not from_email:
|
|
787
|
+
from_email = user_email
|
|
788
|
+
elif from_email:
|
|
789
|
+
return json.dumps({
|
|
790
|
+
"error": "Authentication required: You must be authenticated to send emails"
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
result = await asyncio.to_thread(
|
|
794
|
+
_send_email_sync, to, fwd_subj, body, from_email, None, None
|
|
795
|
+
)
|
|
796
|
+
return json.dumps(result, ensure_ascii=False)
|
|
797
|
+
except Exception as e:
|
|
798
|
+
return json.dumps({"error": str(e)})
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@mcp.tool()
|
|
802
|
+
async def batch_delete_messages(ids: List[str]) -> str:
|
|
803
|
+
"""Delete multiple messages using Mailpit's batch delete API.
|
|
804
|
+
|
|
805
|
+
CRITICAL: Mailpit API behavior:
|
|
806
|
+
- DELETE /api/v1/messages with body {"IDs": ["id1", "id2"]} → deletes specified messages
|
|
807
|
+
- DELETE /api/v1/messages with NO body → deletes ALL messages!
|
|
808
|
+
|
|
809
|
+
We MUST ensure the JSON body is always sent with proper Content-Type header.
|
|
810
|
+
"""
|
|
811
|
+
try:
|
|
812
|
+
client = await get_http()
|
|
813
|
+
valid_ids = [mid.strip() for mid in (ids or []) if mid and mid.strip()]
|
|
814
|
+
|
|
815
|
+
if not valid_ids:
|
|
816
|
+
return json.dumps({"error": "No valid IDs provided"})
|
|
817
|
+
|
|
818
|
+
# Mailpit batch delete: DELETE /api/v1/messages with {"IDs": ["id1", "id2", ...]}
|
|
819
|
+
# Note: uppercase "IDs" is required!
|
|
820
|
+
payload = {"IDs": valid_ids}
|
|
821
|
+
|
|
822
|
+
# httpx.delete() doesn't support json parameter, use request() instead
|
|
823
|
+
headers = {
|
|
824
|
+
"Content-Type": "application/json",
|
|
825
|
+
"Accept-Encoding": "identity",
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
r = await client.request(
|
|
829
|
+
"DELETE",
|
|
830
|
+
MAILPIT_MESSAGES_API,
|
|
831
|
+
content=json.dumps(payload),
|
|
832
|
+
headers=headers
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
if r.status_code in (200, 204):
|
|
836
|
+
return json.dumps({"deleted": valid_ids, "failed": []}, ensure_ascii=False)
|
|
837
|
+
elif r.status_code == 404:
|
|
838
|
+
# Idempotent: treat 404 as success (already deleted)
|
|
839
|
+
return json.dumps({"deleted": valid_ids, "failed": []}, ensure_ascii=False)
|
|
840
|
+
else:
|
|
841
|
+
return json.dumps({
|
|
842
|
+
"error": r.text,
|
|
843
|
+
"status": r.status_code,
|
|
844
|
+
"deleted": [],
|
|
845
|
+
"failed": [{"id": mid, "error": r.text} for mid in valid_ids]
|
|
846
|
+
})
|
|
847
|
+
except Exception as e:
|
|
848
|
+
return json.dumps({"error": str(e)})
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _normalize_recipients(value: Any) -> List[str]:
|
|
852
|
+
"""Accept str (comma/semicolon separated) or list of str; return flat list of emails."""
|
|
853
|
+
parts: List[str] = []
|
|
854
|
+
if value is None:
|
|
855
|
+
return parts
|
|
856
|
+
if isinstance(value, list):
|
|
857
|
+
for v in value:
|
|
858
|
+
if isinstance(v, str) and v.strip():
|
|
859
|
+
parts.append(v)
|
|
860
|
+
elif isinstance(value, str):
|
|
861
|
+
# Split by comma/semicolon; also accept single address
|
|
862
|
+
for seg in value.replace(";", ",").split(","):
|
|
863
|
+
if seg.strip():
|
|
864
|
+
parts.append(seg.strip())
|
|
865
|
+
else:
|
|
866
|
+
parts.append(str(value))
|
|
867
|
+
# Parse into pure emails
|
|
868
|
+
emails = [addr for _, addr in getaddresses(parts) if addr]
|
|
869
|
+
# Fallback: if parsing removed everything, keep raw parts
|
|
870
|
+
return emails or parts
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _header_join(addresses: List[str]) -> str:
|
|
874
|
+
return ", ".join(addresses)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _send_email_sync(to: Any,
|
|
878
|
+
subject: Optional[str],
|
|
879
|
+
body: Optional[str],
|
|
880
|
+
from_email: Optional[str],
|
|
881
|
+
cc: Optional[Any],
|
|
882
|
+
bcc: Optional[Any]) -> Dict[str, Any]:
|
|
883
|
+
sender = from_email or "noreply@example.com"
|
|
884
|
+
to_list = _normalize_recipients(to)
|
|
885
|
+
cc_list = _normalize_recipients(cc)
|
|
886
|
+
bcc_list = _normalize_recipients(bcc)
|
|
887
|
+
|
|
888
|
+
if not to_list:
|
|
889
|
+
raise ValueError("to is required")
|
|
890
|
+
|
|
891
|
+
msg = EmailMessage()
|
|
892
|
+
msg["From"] = sender
|
|
893
|
+
msg["To"] = _header_join(to_list)
|
|
894
|
+
if cc_list:
|
|
895
|
+
msg["Cc"] = _header_join(cc_list)
|
|
896
|
+
msg["Subject"] = subject or "Test Email"
|
|
897
|
+
msg.set_content(body or "This is a test email from Mailpit MCP.")
|
|
898
|
+
|
|
899
|
+
recipients: List[str] = []
|
|
900
|
+
recipients.extend(to_list)
|
|
901
|
+
recipients.extend(cc_list)
|
|
902
|
+
recipients.extend(bcc_list)
|
|
903
|
+
|
|
904
|
+
with smtplib.SMTP(MAILPIT_SMTP_HOST, MAILPIT_SMTP_PORT, local_hostname="localhost") as smtp:
|
|
905
|
+
smtp.send_message(msg, from_addr=sender, to_addrs=recipients)
|
|
906
|
+
return {"ok": True, "to": recipients, "from": sender, "subject": msg["Subject"]}
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@mcp.tool()
|
|
910
|
+
async def send_email(to: Any,
|
|
911
|
+
subject: Optional[str] = None,
|
|
912
|
+
body: Optional[str] = None,
|
|
913
|
+
from_email: Optional[str] = None,
|
|
914
|
+
cc: Optional[Any] = None,
|
|
915
|
+
bcc: Optional[Any] = None) -> str:
|
|
916
|
+
"""Send a new email message.
|
|
917
|
+
|
|
918
|
+
Args:
|
|
919
|
+
to: Recipient email address(es) - REQUIRED. Can be a single address or comma-separated list.
|
|
920
|
+
subject: Email subject line
|
|
921
|
+
body: Email body content (plain text)
|
|
922
|
+
from_email: Sender email address (must match your authenticated email, auto-filled if not provided)
|
|
923
|
+
cc: CC recipients (comma-separated or list)
|
|
924
|
+
bcc: BCC recipients (comma-separated or list)
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
JSON object with send status including "ok", "to", "from", and "subject" fields
|
|
928
|
+
|
|
929
|
+
Security: You can only send from your own authenticated email address.
|
|
930
|
+
The from_email will be automatically set to your authenticated email if not provided.
|
|
931
|
+
|
|
932
|
+
Example:
|
|
933
|
+
send_email(to="bob@example.com", subject="Hello", body="Hi Bob!")
|
|
934
|
+
"""
|
|
935
|
+
if to is None or (isinstance(to, str) and not to.strip()):
|
|
936
|
+
return json.dumps({"error": "to is required"})
|
|
937
|
+
|
|
938
|
+
try:
|
|
939
|
+
# Get current user's email if authenticated
|
|
940
|
+
user_email = await _get_current_user_email()
|
|
941
|
+
|
|
942
|
+
# If authenticated, enforce sender verification
|
|
943
|
+
if user_email:
|
|
944
|
+
# If from_email is provided, verify it matches user's email
|
|
945
|
+
if from_email:
|
|
946
|
+
if from_email.lower().strip() != user_email.lower().strip():
|
|
947
|
+
return json.dumps({
|
|
948
|
+
"error": f"Permission denied: You can only send from your own email address ({user_email}), but attempted to send from {from_email}"
|
|
949
|
+
})
|
|
950
|
+
else:
|
|
951
|
+
# If no from_email provided, use user's email
|
|
952
|
+
from_email = user_email
|
|
953
|
+
elif from_email:
|
|
954
|
+
# If not authenticated but from_email is provided, reject for security
|
|
955
|
+
return json.dumps({
|
|
956
|
+
"error": "Authentication required: You must be authenticated to send emails"
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
result = await asyncio.to_thread(
|
|
960
|
+
_send_email_sync, to, subject, body, from_email, cc, bcc
|
|
961
|
+
)
|
|
962
|
+
return json.dumps(result, ensure_ascii=False)
|
|
963
|
+
except Exception as e:
|
|
964
|
+
return json.dumps({"error": str(e)})
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def main():
|
|
968
|
+
import sys
|
|
969
|
+
print("Starting Gmail MCP server (Mailpit sandbox backend)...", file=sys.stderr)
|
|
970
|
+
sys.stderr.flush()
|
|
971
|
+
|
|
972
|
+
# Prefer explicit PORT env (for dynamic / per-task allocation), then
|
|
973
|
+
# fall back to static registry configuration.
|
|
974
|
+
env_port = os.getenv("PORT", "").strip()
|
|
975
|
+
|
|
976
|
+
def _port_from_registry(default_port: int) -> int:
|
|
977
|
+
try:
|
|
978
|
+
if yaml is None:
|
|
979
|
+
return default_port
|
|
980
|
+
registry_path = Path(__file__).resolve().parent.parent / "registry.yaml"
|
|
981
|
+
if not registry_path.exists():
|
|
982
|
+
return default_port
|
|
983
|
+
data = yaml.safe_load(registry_path.read_text()) or {}
|
|
984
|
+
service_name = Path(__file__).resolve().parent.name # 'gmail'
|
|
985
|
+
for srv in (data.get("servers") or []):
|
|
986
|
+
if isinstance(srv, dict) and srv.get("name") == service_name:
|
|
987
|
+
env = srv.get("env") or {}
|
|
988
|
+
port_str = str(env.get("PORT") or "").strip().strip('\"')
|
|
989
|
+
return int(port_str) if port_str else default_port
|
|
990
|
+
except Exception:
|
|
991
|
+
return default_port
|
|
992
|
+
return default_port
|
|
993
|
+
|
|
994
|
+
if env_port.isdigit():
|
|
995
|
+
port = int(env_port)
|
|
996
|
+
else:
|
|
997
|
+
# Prioritize registry configuration for port selection (default 8840)
|
|
998
|
+
port = _port_from_registry(8840)
|
|
999
|
+
|
|
1000
|
+
mcp.run(transport="http", host="0.0.0.0", port=port)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
if __name__ == "__main__":
|
|
1004
|
+
main()
|