Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Google Mail Capabilities
|
||||
|
||||
A mock email server with a realistic closed-world simulation. The agent has one or more mailboxes, each with contacts, folders, and emails. Only contacts in the address book can receive messages — sending to unknown addresses fails with a bounce. Supports multi-mailbox worlds where the agent can operate across multiple personas.
|
||||
|
||||
## What the agent can do
|
||||
|
||||
**Read and search email.** Browse by folder, read individual emails (which marks them as read), and search across all emails by keyword. Search supports Gmail-style operators: `from:`, `to:`, `cc:`, `bcc:`, `subject:`, `has:attachment`, `filename:`, `is:unread`, `is:read`, `is:important`, `in:`, `label:`, `before:`, `after:`, `newer_than:`, `older_than:`, `-term` (exclusion), and adjacent-term `OR`. Parenthesized boolean grouping is not supported. Check unread counts per folder and overall mailbox statistics.
|
||||
|
||||
**Send, reply, and forward.** Compose new emails to any contact, reply to existing threads (with proper Re: prefixes and quoted text), forward emails to other contacts. Replies maintain threading via message ID references. Sent mail automatically also gets an INBOX copy when the sender is also a recipient (self-send).
|
||||
|
||||
**Manage contacts.** List all contacts, search by name or email, add new contacts (individual or group), edit contact details, and delete contacts. Group contacts have member lists — sending to a group delivers to all members.
|
||||
|
||||
**Organize with folders.** View all folders with message counts, create custom folders, move emails between folders (including in bulk), and delete custom folders (moves their emails back to INBOX). System folders (INBOX, Sent, Drafts, Trash, Scheduled) cannot be deleted.
|
||||
|
||||
**Work with drafts.** Save draft emails, view all drafts, update draft content, and delete drafts.
|
||||
|
||||
**Schedule emails.** Schedule an email for future delivery with a specific date/time, view all scheduled emails, and cancel scheduled sends. Since this is a mock, scheduled emails are stored but not actually delivered at the scheduled time.
|
||||
|
||||
**Attachments and utilities.** Download attachments from emails, get technical email headers (message IDs, routing info).
|
||||
|
||||
**Multi-mailbox.** When a world defines multiple mailboxes, `list_mailboxes` returns all available mailbox IDs + email addresses, and every tool accepts an optional `mailbox_id` argument (defaults to `"default"`). Each mailbox's contacts, folders, and emails are isolated. State round-trips through a `{"mailboxes": {id: {...}}}` wrapper.
|
||||
|
||||
## Coverage gaps
|
||||
|
||||
- No email rules or filters (auto-sort, auto-reply)
|
||||
- No rich text composition (HTML editor)
|
||||
- No email templates
|
||||
- Scheduled emails are stored but do not actually fire
|
||||
|
||||
## Toolsets
|
||||
|
||||
28 tools total. Toolsets map to `WORLDBENCH_TOOL_SETS` values (prefixed form — e.g., `google_mail_core`).
|
||||
|
||||
| Toolset | Tools | Description |
|
||||
|---------|-------|-------------|
|
||||
| `all` / `google_mail_all` | 28 | Everything |
|
||||
| `read` / `google_mail_read` | 11 | All read-only tools |
|
||||
| `write` / `google_mail_write` | 17 | All write tools |
|
||||
| `google_mail_core` | 20 | Basic inbox plus legacy Toolathlon mail/folder/draft operations |
|
||||
| `google_mail_contacts` | 5 | Contact management: get, search, add, edit, delete |
|
||||
| `google_mail_folders` | 4 | Folder organization: get, create, delete, move emails |
|
||||
| `google_mail_drafts` | 4 | Drafts: get, save, update, delete |
|
||||
| `google_mail_scheduling` | 3 | Schedule, list scheduled, cancel scheduled |
|
||||
| `google_mail_toolathlon_legacy` | 20 | Legacy Toolathlon tool subset (pre-integration) |
|
||||
| `google_mail_state` | 2 | `export_state`, `import_state` for fixture seeding and grading |
|
||||
|
||||
**Multi-mailbox** worlds round-trip through `export_state` / `import_state` under a `{"mailboxes": {mailbox_id: {...}}}` wrapper; single-mailbox worlds use the flat `MailboxData` shape.
|
||||
@@ -0,0 +1,319 @@
|
||||
# Google Mail MCP
|
||||
|
||||
A mock Gmail MCP (Model Context Protocol) server for testing and RL environment training. It simulates a complete email system without requiring any network connectivity - all state is loaded from and persisted to a JSON file.
|
||||
|
||||
## Overview
|
||||
|
||||
This MCP server provides 20 email tools that operate on a "closed-world" simulation:
|
||||
|
||||
- **Closed-world contacts**: Only predefined email addresses can receive messages
|
||||
- **Bounce simulation**: Emails to invalid addresses generate realistic bounce notifications
|
||||
- **Group support**: Sending to a group delivers copies to members (including yourself if you're a member)
|
||||
- **State persistence**: All changes are saved back to the JSON file
|
||||
- **No network required**: Everything runs locally from a JSON file
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/google_mail/
|
||||
├── google_mail.py # Entry point for container environment
|
||||
├── utils.py # Utilities for data path computation
|
||||
├── __init__.py # Package exports
|
||||
├── src/mail_mcp/ # Core MCP server implementation
|
||||
│ ├── server.py # FastMCP server with tool definitions
|
||||
│ ├── models/ # Pydantic schemas
|
||||
│ └── services/ # Mailbox business logic
|
||||
├── examples/
|
||||
│ └── sample_mailbox.json
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
cd packages/google_mail
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Container Environment
|
||||
|
||||
The MCP server is pre-installed in the DAT container. Data is stored in `external_services/mailbox.json` next to the agent workspace (outside the agent's filesystem access).
|
||||
|
||||
## Usage
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# Using environment variable
|
||||
MAIL_MCP_DATA_PATH=/path/to/mailbox.json uv run mail-mcp
|
||||
|
||||
# Using CLI argument
|
||||
uv run mail-mcp --data-path /path/to/mailbox.json
|
||||
|
||||
# With debug logging
|
||||
uv run mail-mcp --data-path /path/to/mailbox.json --debug
|
||||
```
|
||||
|
||||
### Container Usage (via MCP Config)
|
||||
|
||||
The server is configured in `configs/mcp_servers/google_mail.yaml`:
|
||||
|
||||
```yaml
|
||||
type: stdio
|
||||
name: google_mail
|
||||
params:
|
||||
command: uv
|
||||
args:
|
||||
- "run"
|
||||
- "--project"
|
||||
- "/workspace/packages/google_mail"
|
||||
- "python"
|
||||
- "/workspace/packages/google_mail/google_mail.py"
|
||||
- "--agent-workspace"
|
||||
- "${agent_workspace}"
|
||||
```
|
||||
|
||||
### Task Preprocessing
|
||||
|
||||
To set up mailbox data for a task, use the utility functions:
|
||||
|
||||
```python
|
||||
from google_mail import create_mail_data
|
||||
from pathlib import Path
|
||||
|
||||
# Copy mailbox.json to external_services location
|
||||
create_mail_data(
|
||||
agent_workspace="/workspace/dumps/workspace",
|
||||
source_mailbox_path=Path("initial_workspace/mailbox.json"),
|
||||
)
|
||||
```
|
||||
|
||||
### MCP Client Configuration
|
||||
|
||||
For direct MCP client usage:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mail": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--project", "/path/to/google_mail", "mail-mcp"],
|
||||
"env": {
|
||||
"MAIL_MCP_DATA_PATH": "/path/to/mailbox.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Email Operations
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `mail_get_emails` | Get paginated emails from a folder |
|
||||
| `mail_read_email` | Read a single email (marks as read) |
|
||||
| `mail_search_emails` | Search emails by query string, including Gmail-style operators such as `from:`, `subject:`, `has:attachment`, `filename:`, `is:unread`, and `in:` |
|
||||
| `mail_send_email` | Send an email |
|
||||
| `mail_reply_email` | Reply to an email |
|
||||
| `mail_forward_email` | Forward an email with attachments |
|
||||
| `mail_delete_emails` | Delete one or more emails (moves to Trash or permanent) |
|
||||
| `mail_move_emails` | Move one or more emails to a different folder |
|
||||
| `mail_mark_emails` | Mark emails as read/unread/important |
|
||||
|
||||
### Folder Operations
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `mail_get_folders` | List all folders with message counts |
|
||||
| `mail_create_folder` | Create a new folder |
|
||||
| `mail_delete_folder` | Delete a folder (system folders protected) |
|
||||
| `mail_get_unread_count` | Get unread counts per folder |
|
||||
| `mail_get_mailbox_stats` | Get statistics for all folders |
|
||||
|
||||
### Draft Operations
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `mail_save_draft` | Save a new draft |
|
||||
| `mail_get_drafts` | Get paginated list of drafts |
|
||||
| `mail_update_draft` | Update an existing draft |
|
||||
| `mail_delete_draft` | Delete a draft |
|
||||
|
||||
### Other
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `mail_get_contacts` | List all valid contacts (closed-world) |
|
||||
| `mail_download_attachment` | Download an attachment as base64 |
|
||||
|
||||
## Threading
|
||||
|
||||
Emails are linked into threads via the `in_reply_to` field, which references the parent email's `message_id`.
|
||||
|
||||
When using `mail_reply_email`:
|
||||
1. The reply's `in_reply_to` is automatically set to the original's `message_id`
|
||||
2. The reply body includes a quoted copy of the original message:
|
||||
|
||||
```
|
||||
Your reply here...
|
||||
|
||||
--- Original Message ---
|
||||
From: alice@example.com
|
||||
Date: 2024-01-15 10:30
|
||||
Subject: Meeting Tomorrow
|
||||
|
||||
Original message content...
|
||||
```
|
||||
|
||||
This provides both programmatic thread linking (via `in_reply_to`) and human-readable context (via quoted original).
|
||||
|
||||
## JSON Schema
|
||||
|
||||
The mailbox data file follows this schema:
|
||||
|
||||
### Root Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"mailbox": { ... },
|
||||
"contacts": [ ... ],
|
||||
"folders": [ ... ],
|
||||
"emails": [ ... ],
|
||||
"drafts": [ ... ],
|
||||
"next_email_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
#### mailbox (required)
|
||||
The identity of the mailbox owner. This is the "From" address when sending emails.
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"name": "Display Name"
|
||||
}
|
||||
```
|
||||
|
||||
#### contacts (required)
|
||||
List of valid email recipients. Only these addresses (plus the mailbox owner) can receive emails. Sending to any other address will generate a bounce notification.
|
||||
|
||||
```json
|
||||
[
|
||||
{"email": "alice@example.com", "name": "Alice Smith"},
|
||||
{"email": "team@example.com", "name": "Engineering Team", "members": [
|
||||
"user@example.com",
|
||||
"alice@example.com"
|
||||
]}
|
||||
]
|
||||
```
|
||||
|
||||
Groups have a `members` array. When you send to a group and you're a member, you receive a copy in your INBOX.
|
||||
|
||||
#### folders (optional)
|
||||
Custom folders beyond the system defaults. System folders (INBOX, Sent, Drafts, Trash) always exist implicitly.
|
||||
|
||||
```json
|
||||
[
|
||||
{"name": "Work"},
|
||||
{"name": "Archive/2024"}
|
||||
]
|
||||
```
|
||||
|
||||
#### emails (optional)
|
||||
List of emails in the mailbox.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "Meeting Tomorrow",
|
||||
"from_addr": "alice@example.com",
|
||||
"to_addr": "user@example.com",
|
||||
"cc_addr": null,
|
||||
"bcc_addr": null,
|
||||
"date": "2024-01-15T10:30:00Z",
|
||||
"message_id": "<msg001@example.com>",
|
||||
"in_reply_to": null,
|
||||
"body_text": "Plain text content here",
|
||||
"body_html": "<p>Optional HTML content</p>",
|
||||
"is_read": false,
|
||||
"is_important": false,
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "document.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQK..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `in_reply_to` field links replies to their parent email via `message_id`. This enables thread tracking.
|
||||
|
||||
#### drafts (optional)
|
||||
List of draft emails.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"draft_id": "draft_1",
|
||||
"subject": "Draft Subject",
|
||||
"body": "Draft content",
|
||||
"html_body": null,
|
||||
"to": "alice@example.com",
|
||||
"cc": null,
|
||||
"bcc": null,
|
||||
"created_at": "2024-01-15T09:00:00Z",
|
||||
"updated_at": "2024-01-15T09:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### next_email_id (optional)
|
||||
Counter for generating new email IDs. Defaults to 1 if not specified.
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal Empty Mailbox
|
||||
|
||||
```json
|
||||
{
|
||||
"mailbox": {
|
||||
"email": "user@example.com",
|
||||
"name": "Test User"
|
||||
},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"drafts": [],
|
||||
"next_email_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Populated Mailbox
|
||||
|
||||
See `examples/sample_mailbox.json` for a complete example with contacts, emails, groups, drafts, and attachments.
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd packages/google_mail
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
uv run ruff check .
|
||||
uv run mypy .
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"run": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"-m",
|
||||
"google_mail"
|
||||
]
|
||||
},
|
||||
"toolsets": {
|
||||
"read": [
|
||||
"download_attachment",
|
||||
"get_contacts",
|
||||
"get_groups",
|
||||
"get_drafts",
|
||||
"get_emails",
|
||||
"get_folders",
|
||||
"get_mailbox_stats",
|
||||
"get_scheduled_emails",
|
||||
"get_unread_count",
|
||||
"list_mailboxes",
|
||||
"search_contacts",
|
||||
"search_groups",
|
||||
"search_emails"
|
||||
],
|
||||
"write": [
|
||||
"add_contact",
|
||||
"add_group",
|
||||
"cancel_scheduled_email",
|
||||
"create_folder",
|
||||
"delete_contact",
|
||||
"delete_group",
|
||||
"delete_draft",
|
||||
"delete_emails",
|
||||
"delete_folder",
|
||||
"edit_contact",
|
||||
"edit_group",
|
||||
"forward_email",
|
||||
"mark_emails",
|
||||
"move_emails",
|
||||
"read_email",
|
||||
"reply_email",
|
||||
"save_draft",
|
||||
"schedule_email",
|
||||
"send_email",
|
||||
"update_draft"
|
||||
],
|
||||
"core": [
|
||||
"forward_email",
|
||||
"get_emails",
|
||||
"read_email",
|
||||
"reply_email",
|
||||
"search_emails",
|
||||
"send_email",
|
||||
"create_folder",
|
||||
"delete_draft",
|
||||
"delete_emails",
|
||||
"delete_folder",
|
||||
"download_attachment",
|
||||
"get_contacts",
|
||||
"get_drafts",
|
||||
"get_folders",
|
||||
"get_mailbox_stats",
|
||||
"get_unread_count",
|
||||
"mark_emails",
|
||||
"move_emails",
|
||||
"save_draft",
|
||||
"update_draft"
|
||||
],
|
||||
"messages": [
|
||||
"delete_emails",
|
||||
"download_attachment",
|
||||
"forward_email",
|
||||
"get_emails",
|
||||
"get_mailbox_stats",
|
||||
"get_unread_count",
|
||||
"list_mailboxes",
|
||||
"mark_emails",
|
||||
"move_emails",
|
||||
"read_email",
|
||||
"reply_email",
|
||||
"search_emails",
|
||||
"send_email"
|
||||
],
|
||||
"contacts": [
|
||||
"add_contact",
|
||||
"add_group",
|
||||
"delete_contact",
|
||||
"delete_group",
|
||||
"edit_contact",
|
||||
"edit_group",
|
||||
"get_contacts",
|
||||
"get_groups",
|
||||
"search_contacts",
|
||||
"search_groups"
|
||||
],
|
||||
"folders": [
|
||||
"create_folder",
|
||||
"delete_folder",
|
||||
"get_folders",
|
||||
"move_emails"
|
||||
],
|
||||
"drafts": [
|
||||
"delete_draft",
|
||||
"get_drafts",
|
||||
"save_draft",
|
||||
"update_draft"
|
||||
],
|
||||
"scheduling": [
|
||||
"cancel_scheduled_email",
|
||||
"get_scheduled_emails",
|
||||
"schedule_email"
|
||||
],
|
||||
"toolathlon_legacy": [
|
||||
"create_folder",
|
||||
"delete_draft",
|
||||
"delete_emails",
|
||||
"delete_folder",
|
||||
"download_attachment",
|
||||
"forward_email",
|
||||
"get_contacts",
|
||||
"get_groups",
|
||||
"get_drafts",
|
||||
"get_emails",
|
||||
"get_folders",
|
||||
"get_mailbox_stats",
|
||||
"get_unread_count",
|
||||
"mark_emails",
|
||||
"move_emails",
|
||||
"read_email",
|
||||
"reply_email",
|
||||
"save_draft",
|
||||
"search_emails",
|
||||
"send_email",
|
||||
"update_draft"
|
||||
],
|
||||
"state": [
|
||||
"export_state",
|
||||
"import_state"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
[build-system]
|
||||
build-backend = "uv_build"
|
||||
requires = [ "uv-build>=0.11,<0.12" ]
|
||||
|
||||
[project]
|
||||
name = "google-mail"
|
||||
version = "0.1.0"
|
||||
description = "Mock email MCP server for RL environment training"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"fastmcp>=3,<4",
|
||||
"pydantic[email]>=2.12.5",
|
||||
]
|
||||
scripts.google-mail = "google_mail:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy>=1.19.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3",
|
||||
"ruff>=0.14.14",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
lint.select = [ "B", "E", "F", "I", "PLW", "SIM", "UP" ]
|
||||
lint.ignore = [ "E501", "PLW0603" ]
|
||||
lint.isort.known-first-party = [ "google_mail" ]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
|
||||
[tool.pytest]
|
||||
ini_options.testpaths = [ "tests" ]
|
||||
ini_options.asyncio_mode = "auto"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Google Mail mock MCP server for RL environment training."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .server import mcp
|
||||
|
||||
__all__ = ["main", "mcp"]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Google Mail MCP Server")
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
|
||||
from .async_tool_guard import assert_tools_async
|
||||
|
||||
assert_tools_async(mcp)
|
||||
|
||||
from google_mail.server import init_state
|
||||
|
||||
init_state()
|
||||
|
||||
port = os.environ.get("PORT")
|
||||
if port:
|
||||
from google_mail.viewer import run_http_server
|
||||
|
||||
run_http_server(mcp, int(port))
|
||||
else:
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point for ``python -m google_mail``."""
|
||||
|
||||
from google_mail import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Boot-time guard: every registered MCP tool must be async.
|
||||
|
||||
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
|
||||
worker threadpool. Under concurrent calls the shared pydantic-core validator is
|
||||
re-entered across threads and panics with ``pyo3_runtime.PanicException:
|
||||
dictionary changed size during iteration``, which tears down the
|
||||
StreamableHTTP task group and turns every later request into a 500. Async
|
||||
(`async def`) tools validate on the single-threaded event loop and are safe.
|
||||
|
||||
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
|
||||
boots every server): a non-conformant package fails fast instead of shipping a
|
||||
server that can crash under load. The same check is intentionally duplicated in
|
||||
each package because the bundled prod images install only that package's own
|
||||
dependencies, so there is no shared runtime module to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _unwrap(fn: Any) -> Any:
|
||||
while isinstance(fn, partial):
|
||||
fn = fn.func
|
||||
return fn
|
||||
|
||||
|
||||
def find_sync_tools(mcp: Any) -> list[str]:
|
||||
"""Return the names of registered tools whose function is not a coroutine."""
|
||||
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
|
||||
manager = getattr(mcp, "_tool_manager", None)
|
||||
if manager is not None and hasattr(manager, "list_tools"):
|
||||
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
|
||||
|
||||
# fastmcp.FastMCP exposes an async API.
|
||||
async def _collect() -> list[str]:
|
||||
sync: list[str] = []
|
||||
for descriptor in await mcp.list_tools():
|
||||
tool = await mcp.get_tool(descriptor.name)
|
||||
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
|
||||
sync.append(descriptor.name)
|
||||
return sorted(sync)
|
||||
|
||||
return asyncio.run(_collect())
|
||||
|
||||
|
||||
def assert_tools_async(mcp: Any) -> None:
|
||||
"""Raise if any registered tool is synchronous."""
|
||||
sync = find_sync_tools(mcp)
|
||||
if sync:
|
||||
raise RuntimeError(
|
||||
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
|
||||
"validation in a worker threadpool and can trigger a pydantic-core "
|
||||
"'dictionary changed size during iteration' panic under concurrent calls, "
|
||||
"which kills the server. Make these tools async: " + ", ".join(sync)
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Schema models for mock mailbox data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Self
|
||||
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
EmailStr,
|
||||
Field,
|
||||
TypeAdapter,
|
||||
ValidationInfo,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
_EMAIL_ADAPTER: TypeAdapter[EmailStr] = TypeAdapter(EmailStr)
|
||||
|
||||
|
||||
def _allow_list_of_strings(schema: dict) -> None:
|
||||
"""Widen a string-typed JSON schema to also advertise ``array[string]``.
|
||||
|
||||
Pydantic emits the declared type only; our ``_coerce_addr_list`` validator
|
||||
accepts lists at runtime, so the advertised schema must match or strict MCP
|
||||
clients will reject list payloads before they reach the validator.
|
||||
"""
|
||||
array_variant = {"type": "array", "items": {"type": "string", "format": "email"}}
|
||||
if "anyOf" in schema:
|
||||
schema["anyOf"].append(array_variant)
|
||||
else:
|
||||
schema.pop("type", None)
|
||||
schema["anyOf"] = [{"type": "string"}, array_variant]
|
||||
|
||||
|
||||
def _validate_email_address(value: str) -> str:
|
||||
"""Validate and normalize a single email address."""
|
||||
return str(_EMAIL_ADAPTER.validate_python(value))
|
||||
|
||||
|
||||
def _validate_address_list(value: str) -> str:
|
||||
"""Validate and normalize a comma-separated email address list."""
|
||||
addresses = [_validate_email_address(address.strip()) for address in value.split(",") if address.strip()]
|
||||
if not addresses:
|
||||
raise ValueError("At least one email address is required")
|
||||
return ", ".join(addresses)
|
||||
|
||||
|
||||
class StrictBaseStateModel(BaseModel):
|
||||
"""Base model for canonical persisted Google Mail state.
|
||||
|
||||
Keep Pydantic's normal JSON coercions, such as parsing datetime strings,
|
||||
but reject fields that are not part of the declared state contract.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
NonEmptyStateString = Annotated[str, Field(min_length=1)]
|
||||
INCOMPLETE_RECIPIENT_FOLDERS: frozenset[str] = frozenset({"Drafts", "Trash"})
|
||||
|
||||
|
||||
class Mailbox(StrictBaseStateModel):
|
||||
"""Identity of the mailbox owner."""
|
||||
|
||||
email: EmailStr = Field(..., description="Email address of the mailbox owner")
|
||||
name: str = Field(..., description="Display name of the mailbox owner")
|
||||
|
||||
|
||||
class Contact(StrictBaseStateModel):
|
||||
"""A valid email contact in the closed-world simulation."""
|
||||
|
||||
email: EmailStr = Field(..., description="Email address of the contact")
|
||||
name: str = Field(..., description="Display name of the contact")
|
||||
|
||||
|
||||
class ContactGroup(StrictBaseStateModel):
|
||||
"""An addressable email group in the closed-world simulation."""
|
||||
|
||||
email: EmailStr = Field(..., description="Email address of the group")
|
||||
name: str = Field(..., description="Display name of the group")
|
||||
members: list[EmailStr] = Field(..., min_length=1, description="Contact emails included in the group")
|
||||
|
||||
|
||||
class Folder(StrictBaseStateModel):
|
||||
"""A custom email folder."""
|
||||
|
||||
name: NonEmptyStateString = Field(..., description="Folder name")
|
||||
|
||||
|
||||
class Attachment(StrictBaseStateModel):
|
||||
"""An email attachment with base64-encoded content."""
|
||||
|
||||
filename: NonEmptyStateString = Field(..., description="Filename of the attachment")
|
||||
content_type: str = Field(..., description="MIME type")
|
||||
content_base64: str = Field(
|
||||
...,
|
||||
description="Base64-encoded file content",
|
||||
json_schema_extra={"contentEncoding": "base64"},
|
||||
)
|
||||
|
||||
@field_validator("content_base64")
|
||||
@classmethod
|
||||
def validate_content_base64(cls, value: str) -> str:
|
||||
try:
|
||||
base64.b64decode(value, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ValueError("Attachment content_base64 must be valid base64") from exc
|
||||
return value
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Return the decoded size of the attachment in bytes."""
|
||||
return len(base64.b64decode(self.content_base64))
|
||||
|
||||
|
||||
class Email(StrictBaseStateModel):
|
||||
"""An email message in the mailbox."""
|
||||
|
||||
email_id: NonEmptyStateString = Field(..., description="Unique email identifier")
|
||||
folder: NonEmptyStateString = Field(..., description="Folder containing the email")
|
||||
subject: str = Field(..., description="Email subject line")
|
||||
from_addr: EmailStr = Field(..., description="Sender email address")
|
||||
to_addr: str = Field(
|
||||
...,
|
||||
description="Recipient(s), comma-separated string or list of strings",
|
||||
json_schema_extra=_allow_list_of_strings,
|
||||
)
|
||||
cc_addr: str | None = Field(
|
||||
default=None,
|
||||
description="CC recipients, comma-separated string or list of strings",
|
||||
json_schema_extra=_allow_list_of_strings,
|
||||
)
|
||||
bcc_addr: str | None = Field(
|
||||
default=None,
|
||||
description="BCC recipients, comma-separated string or list of strings",
|
||||
json_schema_extra=_allow_list_of_strings,
|
||||
)
|
||||
date: datetime = Field(..., description="Date/time the email was sent")
|
||||
message_id: NonEmptyStateString = Field(..., description="RFC 2822 Message-ID")
|
||||
in_reply_to: str | None = Field(default=None, description="Message-ID of the email this is replying to")
|
||||
body_text: str = Field(..., description="Plain text body")
|
||||
body_html: str | None = Field(default=None, description="HTML body")
|
||||
is_read: bool = Field(default=False, description="Whether the email has been read")
|
||||
is_important: bool = Field(default=False, description="Whether the email is marked important")
|
||||
labels: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Search labels attached to the email",
|
||||
validation_alias=AliasChoices("labels", "labelIds", "label_ids"),
|
||||
)
|
||||
attachments: list[Attachment] = Field(default_factory=list, description="Email attachments")
|
||||
scheduled_time: datetime | None = Field(default=None, description="Scheduled send time (None if not scheduled)")
|
||||
|
||||
# Synthetic-data generators often emit ["a@x", "b@y"] for recipient fields
|
||||
# even though the canonical form is "a@x, b@y"; accept both on import.
|
||||
@field_validator("to_addr", "cc_addr", "bcc_addr", mode="before")
|
||||
@classmethod
|
||||
def _coerce_addr_list(cls, value: object) -> object:
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(a).strip() for a in value if a and str(a).strip())
|
||||
return value
|
||||
|
||||
@field_validator("to_addr", "cc_addr", "bcc_addr")
|
||||
@classmethod
|
||||
def validate_addr_list(cls, value: str | None, info: ValidationInfo) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not value.strip():
|
||||
if info.field_name == "to_addr":
|
||||
return ""
|
||||
return None
|
||||
return _validate_address_list(value)
|
||||
|
||||
@field_validator("labels")
|
||||
@classmethod
|
||||
def normalize_labels(cls, value: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for label in value:
|
||||
stripped = str(label).strip()
|
||||
if not stripped or stripped in seen:
|
||||
continue
|
||||
normalized.append(stripped)
|
||||
seen.add(stripped)
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_attachment_filenames_are_unique(self) -> Self:
|
||||
"""Ensure attachment filename lookups are unambiguous within an email."""
|
||||
filenames = [attachment.filename for attachment in self.attachments]
|
||||
if len(filenames) != len(set(filenames)):
|
||||
raise ValueError("Duplicate attachment filenames")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_scheduled_time_matches_folder(self) -> Self:
|
||||
"""Keep scheduled_time consistent with the Scheduled folder."""
|
||||
if self.folder == "Scheduled" and self.scheduled_time is None:
|
||||
raise ValueError("Scheduled emails must have scheduled_time")
|
||||
if self.folder != "Scheduled" and self.scheduled_time is not None:
|
||||
raise ValueError("Only Scheduled emails may have scheduled_time")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_recipient_presence_matches_folder(self) -> Self:
|
||||
"""Allow incomplete drafts/trash, but require recipients for active messages."""
|
||||
if self.folder not in INCOMPLETE_RECIPIENT_FOLDERS and not any(
|
||||
[self.to_addr.strip(), self.cc_addr, self.bcc_addr]
|
||||
):
|
||||
raise ValueError("Active emails require at least one recipient")
|
||||
return self
|
||||
|
||||
|
||||
SYSTEM_FOLDERS: frozenset[str] = frozenset({"INBOX", "Sent", "Drafts", "Trash", "Scheduled"})
|
||||
MailboxId = Annotated[str, Field(min_length=1, description="Mailbox identifier")]
|
||||
|
||||
|
||||
class MailboxData(StrictBaseStateModel):
|
||||
"""Root schema for mock mailbox data."""
|
||||
|
||||
mailbox: Mailbox = Field(..., description="Identity of the mailbox owner")
|
||||
contacts: list[Contact] = Field(default_factory=list, description="Valid contacts")
|
||||
groups: list[ContactGroup] = Field(default_factory=list, description="Addressable contact groups")
|
||||
folders: list[Folder] = Field(default_factory=list, description="Custom folders")
|
||||
emails: list[Email] = Field(default_factory=list, description="All emails (including drafts in Drafts folder)")
|
||||
next_email_id: int = Field(default=1, ge=1, description="Next email ID counter")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_contacts_and_groups_have_unique_emails(self) -> Self:
|
||||
"""Ensure no duplicate address book entries across contacts and groups."""
|
||||
emails = [c.email.lower() for c in self.contacts]
|
||||
emails.extend(group.email.lower() for group in self.groups)
|
||||
if len(emails) != len(set(emails)):
|
||||
raise ValueError("Duplicate email addresses in contacts or groups")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_folders_have_unique_names(self) -> Self:
|
||||
"""Ensure custom folder names are unique."""
|
||||
folder_names = [f.name for f in self.folders]
|
||||
if len(folder_names) != len(set(folder_names)):
|
||||
raise ValueError("Duplicate folder names")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_folders_are_custom_only(self) -> Self:
|
||||
"""Ensure fixed system folders are not persisted as custom folders."""
|
||||
system_folders = sorted({folder.name for folder in self.folders if folder.name in SYSTEM_FOLDERS})
|
||||
if system_folders:
|
||||
raise ValueError(f"System folders must not be listed as custom folders: {', '.join(system_folders)}")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_emails_have_unique_ids(self) -> Self:
|
||||
"""Ensure every email has a unique ID."""
|
||||
email_ids = [e.email_id for e in self.emails]
|
||||
if len(email_ids) != len(set(email_ids)):
|
||||
raise ValueError("Duplicate email IDs")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_next_email_id_is_unused(self) -> Self:
|
||||
"""Ensure the numeric ID counter will not collide with imported emails."""
|
||||
numeric_email_ids = [int(email.email_id) for email in self.emails if email.email_id.isdecimal()]
|
||||
if numeric_email_ids and self.next_email_id <= max(numeric_email_ids):
|
||||
raise ValueError("next_email_id must be greater than all numeric email IDs")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_email_folders_exist(self) -> Self:
|
||||
"""Ensure every email references a known system or custom folder."""
|
||||
folder_names = self.get_all_folder_names()
|
||||
unknown_folders = sorted({email.folder for email in self.emails if email.folder not in folder_names})
|
||||
if unknown_folders:
|
||||
raise ValueError(f"Email references unknown folders: {', '.join(unknown_folders)}")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_group_members(self) -> Self:
|
||||
"""Ensure groups reference known person contacts or the mailbox owner."""
|
||||
valid_member_emails = {self.mailbox.email.lower()} | {contact.email.lower() for contact in self.contacts}
|
||||
for group in self.groups:
|
||||
normalized_members = [member.strip().lower() for member in group.members]
|
||||
if any(not member for member in normalized_members):
|
||||
raise ValueError(f"Group has empty members: {group.email}")
|
||||
if len(normalized_members) != len(set(normalized_members)):
|
||||
raise ValueError(f"Group has duplicate members: {group.email}")
|
||||
if group.email.lower() in normalized_members:
|
||||
raise ValueError(f"Group cannot include itself as a member: {group.email}")
|
||||
unknown_members = sorted(set(normalized_members) - valid_member_emails)
|
||||
if unknown_members:
|
||||
raise ValueError(f"Group references unknown members for {group.email}: {', '.join(unknown_members)}")
|
||||
return self
|
||||
|
||||
def get_all_folder_names(self) -> set[str]:
|
||||
"""Return all folder names (system + custom)."""
|
||||
custom = {f.name for f in self.folders}
|
||||
return set(SYSTEM_FOLDERS) | custom
|
||||
|
||||
def get_contact_by_email(self, email: str) -> Contact | None:
|
||||
"""Find a contact by email address (case-insensitive)."""
|
||||
for contact in self.contacts:
|
||||
if contact.email.lower() == email.lower():
|
||||
return contact
|
||||
return None
|
||||
|
||||
def get_group_by_email(self, email: str) -> ContactGroup | None:
|
||||
"""Find a group by email address (case-insensitive)."""
|
||||
for group in self.groups:
|
||||
if group.email.lower() == email.lower():
|
||||
return group
|
||||
return None
|
||||
|
||||
def is_valid_recipient(self, email: str) -> bool:
|
||||
"""Check if an email address is a valid recipient (closed-world)."""
|
||||
return (
|
||||
email.lower() == self.mailbox.email.lower()
|
||||
or self.get_contact_by_email(email) is not None
|
||||
or self.get_group_by_email(email) is not None
|
||||
)
|
||||
|
||||
def is_mailbox_member_of_group(self, group: ContactGroup) -> bool:
|
||||
"""Check if the mailbox owner is a member of a group contact."""
|
||||
mailbox_email = self.mailbox.email.lower()
|
||||
return any(member.lower() == mailbox_email for member in group.members)
|
||||
|
||||
|
||||
class MultiMailboxData(StrictBaseStateModel):
|
||||
"""Root schema for multi-mailbox Google Mail state."""
|
||||
|
||||
mailboxes: dict[MailboxId, MailboxData] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Mailbox state keyed by mailbox identifier.",
|
||||
)
|
||||
|
||||
|
||||
type GoogleMailState = MailboxData | MultiMailboxData
|
||||
@@ -0,0 +1,910 @@
|
||||
"""Mock email MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
from pydantic import EmailStr
|
||||
|
||||
from google_mail.models import GoogleMailState
|
||||
from google_mail.state import init_state as init_mail_state
|
||||
from google_mail.state import write_snapshots
|
||||
from google_mail.tools import contacts, drafts, folders, messages, scheduling
|
||||
from google_mail.tools import state as state_tools
|
||||
from google_mail.tools.common import (
|
||||
AttachmentFilename,
|
||||
AttachmentPaths,
|
||||
DraftId,
|
||||
EmailId,
|
||||
EmailIds,
|
||||
FolderName,
|
||||
GroupMembers,
|
||||
MailboxIdArg,
|
||||
PageNumber,
|
||||
PageSize,
|
||||
ScheduleTime,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_on_write(fn):
|
||||
"""Decorator: dual-write the post-tool snapshot.
|
||||
|
||||
Writes ``<BUNDLE_OUTPUT_DIR>/state.json`` (per-service bundle subdir,
|
||||
nested ``services/<name>/state.json`` layout) and the legacy
|
||||
``final.json`` so consumers on either convention keep working.
|
||||
"""
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
result = await fn(*args, **kwargs)
|
||||
write_snapshots()
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def init_state() -> None:
|
||||
"""Eagerly initialize mailbox(es) and write the initial state snapshot."""
|
||||
init_mail_state()
|
||||
|
||||
|
||||
mcp = FastMCP("google_mail")
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="list_mailboxes",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
|
||||
)
|
||||
async def mail_list_mailboxes() -> str:
|
||||
"""List all available mailboxes.
|
||||
|
||||
Returns:
|
||||
JSON with mailbox IDs, email addresses, and names.
|
||||
"""
|
||||
return await messages.list_mailboxes()
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_emails",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get Emails",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_get_emails(
|
||||
folder: FolderName | None = None,
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Get emails from the mailbox, optionally filtered by folder.
|
||||
|
||||
Returns a paginated list of emails sorted by date (newest first).
|
||||
|
||||
Args:
|
||||
folder: Folder to filter by (optional)
|
||||
page: Page number (1-indexed)
|
||||
page_size: Results per page (max 100)
|
||||
|
||||
Returns:
|
||||
JSON with emails and pagination info.
|
||||
"""
|
||||
return await messages.get_emails(folder=folder, page=page, page_size=page_size, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="read_email",
|
||||
annotations=ToolAnnotations(
|
||||
title="Read Email",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_read_email(email_id: EmailId, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Read an email and mark it as read.
|
||||
|
||||
Returns the full email content including body and attachments.
|
||||
|
||||
Args:
|
||||
email_id: ID of the email to read
|
||||
|
||||
Returns:
|
||||
JSON with full email details.
|
||||
"""
|
||||
return await messages.read_email(email_id=email_id, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="search_emails",
|
||||
annotations=ToolAnnotations(
|
||||
title="Search Emails",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_search_emails(
|
||||
query: str,
|
||||
folder: FolderName | None = None,
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Search emails by query string. Supports Gmail-style operators.
|
||||
|
||||
Bare words are ANDed together and matched across subject, body, from,
|
||||
and to. Double-quoted segments require exact adjacency. Combine freely,
|
||||
e.g. `from:alice after:2026/03/01 invoice`.
|
||||
|
||||
Operators:
|
||||
from:alice match sender substring (alice@... hits this)
|
||||
to:bob match to recipient substring (strict; use cc:/bcc: for other fields)
|
||||
cc:alice / bcc:alice match cc/bcc recipient substring
|
||||
subject:meeting match subject substring
|
||||
has:attachment messages with one or more attachments
|
||||
filename:agenda.pdf match attachment filename substring
|
||||
is:unread state filters: unread, read, important
|
||||
in:sent exact folder match
|
||||
label:client exact label match when labels exist; otherwise folder-style match
|
||||
"exact phrase" match a contiguous phrase
|
||||
-term exclude messages containing term (works with operators, e.g. -from:bob)
|
||||
invoice OR billing boolean OR (uppercase; lowercase 'or' is literal)
|
||||
before:2026/04/01 messages strictly before date (YYYY/MM/DD or YYYY-MM-DD)
|
||||
after:2026/04/01 messages on or after date
|
||||
newer_than:7d messages within last N days (d), months (m = 30d), or years (y = 365d)
|
||||
older_than:1y messages older than N d|m|y
|
||||
|
||||
Parenthesized boolean grouping is not supported; `OR` joins adjacent terms only.
|
||||
Unsupported or malformed search syntax is reported in a `warnings` array
|
||||
while preserving best-effort search behavior.
|
||||
|
||||
Args:
|
||||
query: Search query (supports the operators above).
|
||||
folder: Folder to limit search (optional).
|
||||
page: Page number (1-indexed).
|
||||
page_size: Results per page (max 100).
|
||||
|
||||
Returns:
|
||||
JSON with matching emails and pagination info.
|
||||
"""
|
||||
return await messages.search_emails(
|
||||
query=query, folder=folder, page=page, page_size=page_size, mailbox_id=mailbox_id
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="send_email",
|
||||
annotations=ToolAnnotations(
|
||||
title="Send Email",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=False,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_send_email(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
attachments: AttachmentPaths | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Send an email.
|
||||
|
||||
Recipients must be valid contacts in your address book.
|
||||
If sending to a group you're a member of, you'll receive a copy.
|
||||
|
||||
Args:
|
||||
to: Recipient(s), comma-separated
|
||||
subject: Email subject
|
||||
body: Plain text email body
|
||||
html_body: HTML body (optional)
|
||||
cc: CC recipients, comma-separated (optional)
|
||||
bcc: BCC recipients, comma-separated (optional)
|
||||
attachments: Paths to files to attach (optional)
|
||||
|
||||
Returns:
|
||||
JSON with sent status and email summary.
|
||||
"""
|
||||
return await messages.send_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
attachments=attachments,
|
||||
mailbox_id=mailbox_id,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="reply_email",
|
||||
annotations=ToolAnnotations(
|
||||
title="Reply to Email",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=False,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_reply_email(
|
||||
email_id: EmailId,
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
reply_all: bool = False,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Reply to an email.
|
||||
|
||||
Creates a reply with proper subject prefix and recipients.
|
||||
|
||||
Args:
|
||||
email_id: ID of the email to reply to
|
||||
body: Reply body text
|
||||
html_body: HTML body (optional)
|
||||
reply_all: Reply to all recipients
|
||||
|
||||
Returns:
|
||||
JSON with sent status and reply email summary.
|
||||
"""
|
||||
return await messages.reply_email(
|
||||
email_id=email_id, body=body, html_body=html_body, reply_all=reply_all, mailbox_id=mailbox_id
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="forward_email",
|
||||
annotations=ToolAnnotations(
|
||||
title="Forward Email",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=False,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_forward_email(
|
||||
email_id: EmailId,
|
||||
to: str,
|
||||
body: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Forward an email to one or more recipients.
|
||||
|
||||
Includes the original message content and any attachments.
|
||||
|
||||
Args:
|
||||
email_id: ID of the email to forward
|
||||
to: Recipient(s) to forward to, comma-separated
|
||||
body: Additional message (optional)
|
||||
|
||||
Returns:
|
||||
JSON with sent status and forwarded email summary.
|
||||
"""
|
||||
return await messages.forward_email(email_id=email_id, to=to, body=body, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="delete_emails",
|
||||
annotations=ToolAnnotations(
|
||||
title="Delete Emails",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=True,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_delete_emails(
|
||||
email_ids: EmailIds,
|
||||
permanent: bool = False,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Delete one or more emails.
|
||||
|
||||
By default, moves them to Trash. Use permanent=True to skip Trash.
|
||||
Deleting from Trash permanently deletes. Partial success is allowed —
|
||||
emails that don't exist surface in the errors list but don't abort the batch.
|
||||
|
||||
Args:
|
||||
email_ids: IDs of the emails to delete (pass a single-element list for one)
|
||||
permanent: Permanently delete (skip Trash)
|
||||
|
||||
Returns:
|
||||
JSON with per-id status and a deleted/failed count.
|
||||
"""
|
||||
return await messages.delete_emails(email_ids=email_ids, permanent=permanent, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="move_emails",
|
||||
annotations=ToolAnnotations(
|
||||
title="Move Emails",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_move_emails(
|
||||
email_ids: EmailIds,
|
||||
target_folder: FolderName,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Move one or more emails to a different folder.
|
||||
|
||||
Partial success is allowed — emails or folders that don't exist surface
|
||||
in the errors list but don't abort the batch.
|
||||
|
||||
Args:
|
||||
email_ids: IDs of the emails to move (pass a single-element list for one)
|
||||
target_folder: Name of the target folder
|
||||
|
||||
Returns:
|
||||
JSON with per-id status and a moved/failed count.
|
||||
"""
|
||||
return await messages.move_emails(email_ids=email_ids, target_folder=target_folder, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="mark_emails",
|
||||
annotations=ToolAnnotations(
|
||||
title="Mark Emails",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_mark_emails(
|
||||
email_ids: EmailIds,
|
||||
is_read: bool | None = None,
|
||||
is_important: bool | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Mark emails as read/unread or important/not important.
|
||||
|
||||
Args:
|
||||
email_ids: List of email IDs to mark
|
||||
is_read: Set read status (optional)
|
||||
is_important: Set important status (optional)
|
||||
|
||||
Returns:
|
||||
JSON with number of emails updated.
|
||||
"""
|
||||
return await messages.mark_emails(
|
||||
email_ids=email_ids, is_read=is_read, is_important=is_important, mailbox_id=mailbox_id
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_folders",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get Folders",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_get_folders(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Get all folders with message counts.
|
||||
|
||||
Returns system folders (INBOX, Sent, Drafts, Trash) and custom folders.
|
||||
|
||||
Returns:
|
||||
JSON with folder list including name, total, unread, is_system.
|
||||
"""
|
||||
return await folders.get_folders(mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="create_folder",
|
||||
annotations=ToolAnnotations(
|
||||
title="Create Folder",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=False,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_create_folder(folder_name: FolderName, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Create a new custom folder.
|
||||
|
||||
Args:
|
||||
folder_name: Name of the folder to create
|
||||
|
||||
Returns:
|
||||
JSON with creation status.
|
||||
"""
|
||||
return await folders.create_folder(folder_name=folder_name, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="delete_folder",
|
||||
annotations=ToolAnnotations(
|
||||
title="Delete Folder",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=True,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_delete_folder(folder_name: FolderName, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Delete a custom folder.
|
||||
|
||||
System folders (INBOX, Sent, Drafts, Trash) cannot be deleted.
|
||||
Emails in the deleted folder are moved to INBOX.
|
||||
|
||||
Args:
|
||||
folder_name: Name of the folder to delete
|
||||
|
||||
Returns:
|
||||
JSON with deletion status.
|
||||
"""
|
||||
return await folders.delete_folder(folder_name=folder_name, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_unread_count",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get Unread Count",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_get_unread_count(folder: FolderName | None = None, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Get unread email count for folders.
|
||||
|
||||
Args:
|
||||
folder: Specific folder (optional)
|
||||
|
||||
Returns:
|
||||
JSON with unread counts per folder.
|
||||
"""
|
||||
return await messages.get_unread_count(folder=folder, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_mailbox_stats",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get Mailbox Stats",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_get_mailbox_stats(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Get overall mailbox statistics.
|
||||
|
||||
Returns owner info, total counts, and per-folder breakdown.
|
||||
|
||||
Returns:
|
||||
JSON with comprehensive mailbox statistics.
|
||||
"""
|
||||
return await messages.get_mailbox_stats(mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="save_draft",
|
||||
annotations=ToolAnnotations(
|
||||
title="Save Draft",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=False,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_save_draft(
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
html_body: str | None = None,
|
||||
to: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Save a new email draft.
|
||||
|
||||
Args:
|
||||
subject: Draft subject
|
||||
body: Draft body
|
||||
html_body: HTML body (optional)
|
||||
to: Recipient(s), comma-separated (optional)
|
||||
cc: CC recipient(s), comma-separated (optional)
|
||||
bcc: BCC recipient(s), comma-separated (optional)
|
||||
|
||||
Returns:
|
||||
JSON with saved draft details.
|
||||
"""
|
||||
return await drafts.save_draft(
|
||||
subject=subject, body=body, html_body=html_body, to=to, cc=cc, bcc=bcc, mailbox_id=mailbox_id
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_drafts",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get Drafts",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_get_drafts(
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Get all drafts with pagination.
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
page_size: Results per page (max 100)
|
||||
|
||||
Returns:
|
||||
JSON with drafts and pagination info.
|
||||
"""
|
||||
return await drafts.get_drafts(page=page, page_size=page_size, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="update_draft",
|
||||
annotations=ToolAnnotations(
|
||||
title="Update Draft",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_update_draft(
|
||||
draft_id: DraftId,
|
||||
subject: str | None = None,
|
||||
body: str | None = None,
|
||||
html_body: str | None = None,
|
||||
to: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Update an existing draft.
|
||||
|
||||
Only provided fields are updated; others remain unchanged.
|
||||
|
||||
Args:
|
||||
draft_id: ID of the draft to update
|
||||
subject: New subject (optional)
|
||||
body: New body (optional)
|
||||
html_body: New HTML body (optional)
|
||||
to: New recipient(s), comma-separated (optional)
|
||||
cc: New CC recipient(s), comma-separated (optional)
|
||||
bcc: New BCC recipient(s), comma-separated (optional)
|
||||
|
||||
Returns:
|
||||
JSON with updated draft details.
|
||||
"""
|
||||
return await drafts.update_draft(
|
||||
draft_id=draft_id, subject=subject, body=body, html_body=html_body, to=to, cc=cc, bcc=bcc, mailbox_id=mailbox_id
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="delete_draft",
|
||||
annotations=ToolAnnotations(
|
||||
title="Delete Draft",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=True,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_delete_draft(draft_id: DraftId, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Delete a draft.
|
||||
|
||||
Args:
|
||||
draft_id: ID of the draft to delete
|
||||
|
||||
Returns:
|
||||
JSON with deletion status.
|
||||
"""
|
||||
return await drafts.delete_draft(draft_id=draft_id, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_contacts",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get Contacts",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_get_contacts(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Get all contacts.
|
||||
|
||||
Returns the person contacts in your address book. Groups are available
|
||||
through get_groups.
|
||||
|
||||
Returns:
|
||||
JSON with contact list.
|
||||
"""
|
||||
return await contacts.get_contacts(mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="download_attachment",
|
||||
annotations=ToolAnnotations(
|
||||
title="Download Attachment",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
)
|
||||
async def mail_download_attachment(
|
||||
email_id: EmailId,
|
||||
filename: AttachmentFilename,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Download an attachment from an email.
|
||||
|
||||
Returns the attachment content as base64-encoded data.
|
||||
|
||||
Args:
|
||||
email_id: ID of the email
|
||||
filename: Name of the attachment file
|
||||
|
||||
Returns:
|
||||
JSON with attachment data.
|
||||
"""
|
||||
return await messages.download_attachment(email_id=email_id, filename=filename, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="search_contacts",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
|
||||
)
|
||||
async def mail_search_contacts(query: str, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Search contacts by name or email address (case-insensitive).
|
||||
|
||||
Args:
|
||||
query: Search string to match against contact name or email
|
||||
"""
|
||||
return await contacts.search_contacts(query=query, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="add_contact",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, idempotentHint=False),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_add_contact(
|
||||
email: EmailStr,
|
||||
name: str,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Add a new contact to the address book.
|
||||
|
||||
Args:
|
||||
email: Email address of the contact
|
||||
name: Display name of the contact
|
||||
"""
|
||||
return await contacts.add_contact(email=email, name=name, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="edit_contact",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, idempotentHint=True),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_edit_contact(
|
||||
email: EmailStr,
|
||||
name: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Update an existing contact's name.
|
||||
|
||||
Args:
|
||||
email: Email address of the contact to update (lookup key)
|
||||
name: New display name (optional, omit to keep current)
|
||||
"""
|
||||
return await contacts.edit_contact(email=email, name=name, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="delete_contact",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_delete_contact(email: EmailStr, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Remove a contact from the address book.
|
||||
|
||||
Args:
|
||||
email: Email address of the contact to delete
|
||||
"""
|
||||
return await contacts.delete_contact(email=email, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_groups",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
|
||||
)
|
||||
async def mail_get_groups(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Get all addressable contact groups."""
|
||||
return await contacts.get_groups(mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="search_groups",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
|
||||
)
|
||||
async def mail_search_groups(query: str, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Search groups by name or email address (case-insensitive)."""
|
||||
return await contacts.search_groups(query=query, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="add_group",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, idempotentHint=False),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_add_group(
|
||||
email: EmailStr,
|
||||
name: str,
|
||||
members: GroupMembers,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Add a new addressable contact group.
|
||||
|
||||
Args:
|
||||
email: Email address of the group
|
||||
name: Display name of the group
|
||||
members: Contact emails included in the group
|
||||
"""
|
||||
return await contacts.add_group(email=email, name=name, members=members, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="edit_group",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, idempotentHint=True),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_edit_group(
|
||||
email: EmailStr,
|
||||
name: str | None = None,
|
||||
members: GroupMembers | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Update an existing contact group."""
|
||||
return await contacts.edit_group(email=email, name=name, members=members, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="delete_group",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_delete_group(email: EmailStr, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Remove a contact group from the address book."""
|
||||
return await contacts.delete_group(email=email, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="schedule_email",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, idempotentHint=False),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_schedule_email(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
scheduled_time: ScheduleTime,
|
||||
html_body: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Schedule an email for later delivery.
|
||||
|
||||
Args:
|
||||
to: Recipient(s), comma-separated
|
||||
subject: Email subject
|
||||
body: Plain text email body
|
||||
scheduled_time: ISO 8601 datetime for when to send (e.g., '2024-12-25T09:00:00Z')
|
||||
html_body: HTML body (optional)
|
||||
cc: CC recipient(s), comma-separated (optional)
|
||||
bcc: BCC recipient(s), comma-separated (optional)
|
||||
"""
|
||||
return await scheduling.schedule_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
scheduled_time=scheduled_time,
|
||||
html_body=html_body,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
mailbox_id=mailbox_id,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="get_scheduled_emails",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
|
||||
)
|
||||
async def mail_get_scheduled_emails(
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
"""Get list of emails scheduled for later delivery."""
|
||||
return await scheduling.get_scheduled_emails(page=page, page_size=page_size, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="cancel_scheduled_email",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def mail_cancel_scheduled_email(email_id: EmailId, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
"""Cancel a scheduled email. The email is permanently removed.
|
||||
|
||||
Args:
|
||||
email_id: ID of the scheduled email to cancel
|
||||
"""
|
||||
return await scheduling.cancel_scheduled_email(email_id=email_id, mailbox_id=mailbox_id)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="export_state",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
|
||||
)
|
||||
async def export_state() -> GoogleMailState:
|
||||
"""Export the full mailbox state as JSON.
|
||||
|
||||
Single-mailbox worlds emit ``MailboxData``; multi-mailbox worlds emit
|
||||
``MultiMailboxData``. Round-trips with import_state.
|
||||
"""
|
||||
return await state_tools.export_state()
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
name="import_state",
|
||||
annotations=ToolAnnotations(readOnlyHint=False, idempotentHint=True),
|
||||
)
|
||||
@_snapshot_on_write
|
||||
async def import_state(state: GoogleMailState) -> dict:
|
||||
"""Replace the full mailbox state with the provided JSON.
|
||||
|
||||
Accepts either the flat MailboxData shape (loaded into the ``default``
|
||||
mailbox) or the multi-mailbox wrapper (``{"mailboxes": {mailbox_id: ...}}``).
|
||||
For synthetic-data injection and test setup.
|
||||
"""
|
||||
return await state_tools.import_state(state=state)
|
||||
@@ -0,0 +1 @@
|
||||
"""Services for the mock email MCP."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
"""State registry, startup loading, and snapshots for the Google Mail mock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from google_mail.models import GoogleMailState, MailboxData, MultiMailboxData
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_NAME = "google_mail"
|
||||
|
||||
_mailboxes: dict[str, MailboxService] = {}
|
||||
_final_path: Path | None = None
|
||||
_bundle_state_path: Path | None = None
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def resolve_bundle_state_paths() -> list[Path]:
|
||||
"""Resolve the seed-state files inside this service's bundle subdir.
|
||||
|
||||
The folder ``<BUNDLEDIR>/services/<name>/`` is the unit: everything in it
|
||||
is this service's seed. Prefer the canonical single-file ``state.json``
|
||||
(the output round-trip shape); otherwise hand back ALL ``*.json`` in the
|
||||
folder (the raw entities layout, e.g. one file per mailbox), read in
|
||||
full the same way INPUTDIR files are.
|
||||
"""
|
||||
bundle_dir = os.environ.get("BUNDLEDIR")
|
||||
if not bundle_dir:
|
||||
return []
|
||||
service_dir = Path(bundle_dir) / "services" / SERVICE_NAME
|
||||
state_file = service_dir / "state.json"
|
||||
if state_file.is_file():
|
||||
return [state_file]
|
||||
if service_dir.is_dir():
|
||||
return sorted(service_dir.glob("*.json"))
|
||||
return []
|
||||
|
||||
|
||||
def resolve_bundle_state_path() -> Path | None:
|
||||
"""Back-compat single-file view of :func:`resolve_bundle_state_paths`."""
|
||||
paths = resolve_bundle_state_paths()
|
||||
return paths[0] if paths else None
|
||||
|
||||
|
||||
def resolve_bundle_output_path() -> Path | None:
|
||||
output_dir = os.environ.get("BUNDLE_OUTPUT_DIR")
|
||||
if not output_dir:
|
||||
return None
|
||||
return Path(output_dir) / "state.json"
|
||||
|
||||
|
||||
def _merge_mailbox_into(target: dict[str, Any], source: dict[str, Any]) -> None:
|
||||
"""Merge a flat mailbox seed into ``target``: lists extend, dicts update,
|
||||
``next_email_id`` takes the max, remaining scalars overwrite."""
|
||||
for key, value in source.items():
|
||||
if key == "next_email_id":
|
||||
target[key] = max(target.get(key, 0), value)
|
||||
elif isinstance(value, list) and isinstance(target.get(key), list):
|
||||
target[key].extend(value)
|
||||
elif isinstance(value, dict) and isinstance(target.get(key), dict):
|
||||
target[key].update(value)
|
||||
else:
|
||||
target[key] = value
|
||||
|
||||
|
||||
def _temporary_json_path(suffix: str) -> Path:
|
||||
"""Create and return a closed temporary JSON path."""
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
return Path(tmp.name)
|
||||
|
||||
|
||||
def get_mailboxes() -> dict[str, MailboxService]:
|
||||
"""Return the live mailbox registry."""
|
||||
return _mailboxes
|
||||
|
||||
|
||||
def set_mailboxes(mailboxes: dict[str, MailboxService]) -> None:
|
||||
"""Replace the live mailbox registry."""
|
||||
_mailboxes.clear()
|
||||
_mailboxes.update(mailboxes)
|
||||
|
||||
|
||||
def get_mailbox(mailbox_id: str = "default") -> MailboxService:
|
||||
"""Get a mailbox service instance by ID."""
|
||||
if not _mailboxes:
|
||||
raise RuntimeError("Mailbox service not initialized")
|
||||
if mailbox_id not in _mailboxes:
|
||||
available = ", ".join(sorted(_mailboxes.keys()))
|
||||
raise ValueError(f"Mailbox '{mailbox_id}' not found. Available: {available}")
|
||||
return _mailboxes[mailbox_id]
|
||||
|
||||
|
||||
def set_snapshot_paths(
|
||||
*,
|
||||
final_path: Path | None | object = _UNSET,
|
||||
bundle_state_path: Path | None | object = _UNSET,
|
||||
) -> None:
|
||||
"""Update post-write snapshot destinations.
|
||||
|
||||
Omitted arguments leave the existing path unchanged. Pass ``None``
|
||||
explicitly to clear a path.
|
||||
"""
|
||||
global _final_path, _bundle_state_path
|
||||
if final_path is not _UNSET:
|
||||
_final_path = None if final_path is None else Path(cast(Path, final_path))
|
||||
if bundle_state_path is not _UNSET:
|
||||
_bundle_state_path = None if bundle_state_path is None else Path(cast(Path, bundle_state_path))
|
||||
|
||||
|
||||
def get_final_path() -> Path | None:
|
||||
"""Return the configured legacy final.json path."""
|
||||
return _final_path
|
||||
|
||||
|
||||
def get_bundle_state_path() -> Path | None:
|
||||
"""Return the configured bundle snapshot path."""
|
||||
return _bundle_state_path
|
||||
|
||||
|
||||
def create_default_mailbox(data_path: Path) -> None:
|
||||
"""Create a default empty mailbox file."""
|
||||
default_email = os.environ.get("MAIL_MCP_DEFAULT_EMAIL", "agent@mail.com")
|
||||
default_name = os.environ.get("MAIL_MCP_DEFAULT_NAME", "Agent")
|
||||
default_data = {
|
||||
"mailbox": {"email": default_email, "name": default_name},
|
||||
"contacts": [],
|
||||
"groups": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
data_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(data_path, "w") as f:
|
||||
json.dump(default_data, f, indent=2)
|
||||
_logger.info("Created default empty mailbox at %s", data_path)
|
||||
|
||||
|
||||
def dump_state(dest: Path, label: str) -> None:
|
||||
"""Write a JSON snapshot of all mailboxes to *dest*."""
|
||||
if not _mailboxes:
|
||||
return
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
snapshot = state_to_json()
|
||||
with open(dest, "w") as f:
|
||||
json.dump(snapshot, f, indent=2, default=str)
|
||||
_logger.info("Wrote %s state to %s", label, dest)
|
||||
except Exception:
|
||||
_logger.exception("Failed to write %s state to %s", label, dest)
|
||||
|
||||
|
||||
def write_snapshots() -> None:
|
||||
"""Write configured post-tool snapshots."""
|
||||
if _bundle_state_path is not None:
|
||||
dump_state(_bundle_state_path, "bundle")
|
||||
if _final_path is not None:
|
||||
dump_state(_final_path, "final")
|
||||
|
||||
|
||||
def configure_snapshots_from_env(*, write_initial: bool = False) -> None:
|
||||
"""Configure snapshot paths from OUTPUTDIR and BUNDLE_OUTPUT_DIR."""
|
||||
outputdir = os.environ.get("OUTPUTDIR")
|
||||
bundle_output_dir = os.environ.get("BUNDLE_OUTPUT_DIR")
|
||||
_logger.info("OUTPUTDIR=%s BUNDLE_OUTPUT_DIR=%s", outputdir or "<unset>", bundle_output_dir or "<unset>")
|
||||
|
||||
bundle_state_path = resolve_bundle_output_path()
|
||||
final_path = Path(outputdir) / "final.json" if outputdir else None
|
||||
set_snapshot_paths(final_path=final_path, bundle_state_path=bundle_state_path)
|
||||
|
||||
if write_initial and bundle_state_path is not None:
|
||||
dump_state(bundle_state_path, "bundle")
|
||||
if write_initial and outputdir:
|
||||
dump_state(Path(outputdir) / "initial.json", "initial")
|
||||
|
||||
|
||||
def state_to_json() -> dict[str, Any]:
|
||||
"""Return the full Google Mail state as a JSON-native dict."""
|
||||
if len(_mailboxes) == 1 and "default" in _mailboxes:
|
||||
return _mailboxes["default"].data.model_dump(mode="json")
|
||||
return {"mailboxes": {mid: svc.data.model_dump(mode="json") for mid, svc in _mailboxes.items()}}
|
||||
|
||||
|
||||
def export_state_model() -> GoogleMailState:
|
||||
"""Return the full Google Mail state as a typed model."""
|
||||
if len(_mailboxes) == 1 and "default" in _mailboxes:
|
||||
return MailboxData.model_validate(_mailboxes["default"].to_json())
|
||||
return MultiMailboxData(
|
||||
mailboxes={mid: MailboxData.model_validate(svc.to_json()) for mid, svc in _mailboxes.items()}
|
||||
)
|
||||
|
||||
|
||||
def state_from_json(state: GoogleMailState | dict[str, Any]) -> None:
|
||||
"""Full-replace the mailbox registry from a JSON-native dict or model."""
|
||||
if isinstance(state, MailboxData | MultiMailboxData):
|
||||
validated_state = state
|
||||
elif "mailboxes" in state:
|
||||
validated_state = MultiMailboxData.model_validate(state)
|
||||
else:
|
||||
validated_state = MailboxData.model_validate(state)
|
||||
|
||||
_mailboxes.clear()
|
||||
if isinstance(validated_state, MultiMailboxData):
|
||||
for mid, mdata in validated_state.mailboxes.items():
|
||||
payload = mdata.model_dump(mode="json")
|
||||
tmp = _temporary_json_path(suffix=f"_{mid}.json")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(payload, f, indent=2, default=str)
|
||||
svc = MailboxService(tmp)
|
||||
svc.from_json(payload, persist=True)
|
||||
_mailboxes[mid] = svc
|
||||
return
|
||||
|
||||
payload = validated_state.model_dump(mode="json")
|
||||
tmp = _temporary_json_path(suffix="_default.json")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(payload, f, indent=2, default=str)
|
||||
_mailboxes["default"] = MailboxService(tmp)
|
||||
_mailboxes["default"].from_json(payload, persist=True)
|
||||
|
||||
|
||||
def init_state() -> None:
|
||||
"""Eagerly initialize mailbox(es) and write the initial state snapshot.
|
||||
|
||||
Supports two data formats:
|
||||
- Single mailbox (backward compat): flat {mailbox, contacts, emails, ...}
|
||||
- Multi-mailbox: {mailboxes: {id: {mailbox, contacts, emails, ...}, ...}}
|
||||
"""
|
||||
if _mailboxes:
|
||||
configure_snapshots_from_env()
|
||||
return
|
||||
|
||||
inputdir = os.environ.get("INPUTDIR")
|
||||
# Bundle folder is coalesced (read in full); INPUTDIR keeps its legacy
|
||||
# first-file-only contract (see scripts/validate_external_services.py).
|
||||
bundle_files = resolve_bundle_state_paths()
|
||||
inputdir_files: list[Path] = []
|
||||
if not bundle_files and inputdir and Path(inputdir).is_dir():
|
||||
inputdir_files = sorted(Path(inputdir).glob("*.json"))[:1]
|
||||
json_files = bundle_files or inputdir_files
|
||||
_logger.info(
|
||||
"BUNDLEDIR=%s INPUTDIR=%s seed_files=%s",
|
||||
os.environ.get("BUNDLEDIR") or "<unset>",
|
||||
inputdir or "<unset>",
|
||||
[str(p) for p in json_files] or "<none>",
|
||||
)
|
||||
|
||||
if json_files:
|
||||
# Coalesce the bundle folder: flat single-mailbox files merge into ONE
|
||||
# default mailbox (the raw entities layout splits one mailbox
|
||||
# across per-entity files, e.g. services/google_mail/inbox.json — those
|
||||
# belong together, not in separate mailboxes). Files carrying a
|
||||
# {mailboxes: {...}} wrapper contribute their named mailboxes. The
|
||||
# legacy INPUTDIR path is a single file, so the loop is a no-op merge.
|
||||
mailboxes_data: dict[str, dict[str, Any]] = {}
|
||||
default_mailbox: dict[str, Any] = {}
|
||||
for data_path in json_files:
|
||||
_logger.info("Loading mailbox(es) from %s", data_path)
|
||||
with open(data_path) as f:
|
||||
raw = json.load(f)
|
||||
if "mailboxes" in raw:
|
||||
mailboxes_data.update(raw["mailboxes"])
|
||||
else:
|
||||
_merge_mailbox_into(default_mailbox, raw)
|
||||
if default_mailbox:
|
||||
mailboxes_data.setdefault("default", default_mailbox)
|
||||
|
||||
for mid, mdata in mailboxes_data.items():
|
||||
tmp = _temporary_json_path(suffix=f"_{mid}.json")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(mdata, f, indent=2)
|
||||
svc = MailboxService(tmp)
|
||||
svc.load()
|
||||
_mailboxes[mid] = svc
|
||||
_logger.info("Loaded mailbox '%s' (%s)", mid, mdata.get("mailbox", {}).get("email", "?"))
|
||||
else:
|
||||
data_path = _temporary_json_path(suffix=".json")
|
||||
create_default_mailbox(data_path)
|
||||
if inputdir:
|
||||
_logger.warning("INPUTDIR set but no .json files found in %s", inputdir)
|
||||
svc = MailboxService(data_path)
|
||||
svc.load()
|
||||
_mailboxes["default"] = svc
|
||||
|
||||
_logger.info("Mail MCP server initialized with %d mailbox(es)", len(_mailboxes))
|
||||
configure_snapshots_from_env(write_initial=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain handlers for Google Mail tools."""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Shared helpers and public argument aliases for Google Mail tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import EmailStr, Field
|
||||
|
||||
from google_mail.models import Email
|
||||
|
||||
PageNumber = Annotated[int, Field(ge=1, description="Page number, starting at 1.")]
|
||||
PageSize = Annotated[int, Field(ge=1, le=100, description="Number of results per page.")]
|
||||
GroupMembers = Annotated[list[EmailStr], Field(min_length=1, description="Contact emails included in the group.")]
|
||||
EmailId = Annotated[str, Field(min_length=1, description="Email ID.")]
|
||||
EmailIds = Annotated[list[EmailId], Field(min_length=1, description="One or more email IDs.")]
|
||||
DraftId = Annotated[str, Field(min_length=1, description="Draft ID.")]
|
||||
FolderName = Annotated[str, Field(min_length=1, description="Folder name.")]
|
||||
MailboxIdArg = Annotated[str, Field(min_length=1, description="Mailbox identifier.")]
|
||||
AttachmentFilename = Annotated[str, Field(min_length=1, description="Attachment filename.")]
|
||||
AttachmentPath = Annotated[str, Field(min_length=1, description="Path to a file to attach.")]
|
||||
AttachmentPaths = Annotated[list[AttachmentPath], Field(min_length=1, description="Paths to files to attach.")]
|
||||
ScheduleTime = Annotated[datetime, Field(description="ISO 8601 scheduled send time.")]
|
||||
|
||||
|
||||
def error_response(error: str, **extra: Any) -> str:
|
||||
"""Format a JSON error response."""
|
||||
return json.dumps({"error": error, **extra})
|
||||
|
||||
|
||||
def success_response(data: dict[str, Any]) -> str:
|
||||
"""Format a JSON success response."""
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
|
||||
def batch_response(
|
||||
*,
|
||||
action: str,
|
||||
requested_count: int,
|
||||
succeeded_ids: list[str],
|
||||
errors: list[dict[str, str]],
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Format a batch-action response with unambiguous aggregate status."""
|
||||
succeeded_count = len(succeeded_ids)
|
||||
failed_count = len(errors)
|
||||
if succeeded_count == requested_count:
|
||||
status = "all_succeeded"
|
||||
summary = f"All {requested_count} attempts succeeded"
|
||||
elif succeeded_count == 0:
|
||||
status = "all_failed"
|
||||
summary = f"All {requested_count} attempts failed"
|
||||
else:
|
||||
status = "partial_success"
|
||||
summary = f"{succeeded_count} of {requested_count} attempts succeeded"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
"action": action,
|
||||
"requestedCount": requested_count,
|
||||
"succeededCount": succeeded_count,
|
||||
"failedCount": failed_count,
|
||||
"succeededIds": succeeded_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
def normalize_scheduled_time(value: datetime | str) -> datetime:
|
||||
"""Normalize parsed or direct-call scheduled_time values."""
|
||||
if isinstance(value, str):
|
||||
value = datetime.fromisoformat(value)
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
def format_email_summary(email: Email) -> dict[str, Any]:
|
||||
"""Format an email for summary output."""
|
||||
return {
|
||||
"email_id": email.email_id,
|
||||
"folder": email.folder,
|
||||
"subject": email.subject,
|
||||
"from": email.from_addr,
|
||||
"to": email.to_addr,
|
||||
"date": email.date.isoformat(),
|
||||
"is_read": email.is_read,
|
||||
"is_important": email.is_important,
|
||||
"has_attachments": len(email.attachments) > 0,
|
||||
}
|
||||
|
||||
|
||||
def format_email_full(email: Email) -> dict[str, Any]:
|
||||
"""Format an email for full output."""
|
||||
result = format_email_summary(email)
|
||||
result["cc"] = email.cc_addr
|
||||
result["bcc"] = email.bcc_addr
|
||||
result["message_id"] = email.message_id
|
||||
result["in_reply_to"] = email.in_reply_to
|
||||
result["body_text"] = email.body_text
|
||||
result["body_html"] = email.body_html
|
||||
result["attachments"] = [
|
||||
{
|
||||
"filename": a.filename,
|
||||
"content_type": a.content_type,
|
||||
"size": a.size,
|
||||
}
|
||||
for a in email.attachments
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def format_draft(draft: Email) -> dict[str, Any]:
|
||||
"""Format a draft (email in Drafts folder) for output."""
|
||||
return {
|
||||
"draft_id": draft.email_id,
|
||||
"subject": draft.subject,
|
||||
"to": draft.to_addr,
|
||||
"cc": draft.cc_addr,
|
||||
"bcc": draft.bcc_addr,
|
||||
"body": draft.body_text,
|
||||
"html_body": draft.body_html,
|
||||
"date": draft.date.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def format_contact(contact: Any) -> dict[str, Any]:
|
||||
"""Format a contact for output."""
|
||||
return {
|
||||
"email": contact.email,
|
||||
"name": contact.name,
|
||||
}
|
||||
|
||||
|
||||
def format_group(group: Any) -> dict[str, Any]:
|
||||
"""Format a group for output."""
|
||||
return {
|
||||
"email": group.email,
|
||||
"name": group.name,
|
||||
"members": group.members,
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Contacts handlers for Google Mail tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import EmailStr
|
||||
|
||||
from google_mail.services.mailbox import (
|
||||
ContactExistsError,
|
||||
ContactInUseError,
|
||||
ContactNotFoundError,
|
||||
GroupExistsError,
|
||||
GroupNotFoundError,
|
||||
)
|
||||
from google_mail.state import get_mailbox
|
||||
from google_mail.tools.common import (
|
||||
GroupMembers,
|
||||
MailboxIdArg,
|
||||
error_response,
|
||||
format_contact,
|
||||
format_group,
|
||||
success_response,
|
||||
)
|
||||
|
||||
|
||||
async def get_contacts(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
contacts = mailbox.get_contacts()
|
||||
return success_response(
|
||||
{
|
||||
"contacts": [
|
||||
{
|
||||
"email": c.email,
|
||||
"name": c.name,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def search_contacts(query: str, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
contacts = mailbox.search_contacts(query)
|
||||
return success_response(
|
||||
{
|
||||
"contacts": [format_contact(c) for c in contacts],
|
||||
"total": len(contacts),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def add_contact(
|
||||
email: EmailStr,
|
||||
name: str,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
contact = mailbox.add_contact(email=email, name=name)
|
||||
return success_response({"status": "created", "contact": format_contact(contact)})
|
||||
except ContactExistsError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def edit_contact(
|
||||
email: EmailStr,
|
||||
name: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
contact = mailbox.edit_contact(email=email, name=name)
|
||||
return success_response({"status": "updated", "contact": format_contact(contact)})
|
||||
except ContactNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def delete_contact(email: EmailStr, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
mailbox.delete_contact(email)
|
||||
return success_response({"status": "deleted", "email": email})
|
||||
except ContactNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
except ContactInUseError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def get_groups(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
groups = mailbox.get_groups()
|
||||
return success_response({"groups": [format_group(group) for group in groups]})
|
||||
|
||||
|
||||
async def search_groups(query: str, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
groups = mailbox.search_groups(query)
|
||||
return success_response(
|
||||
{
|
||||
"groups": [format_group(group) for group in groups],
|
||||
"total": len(groups),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def add_group(
|
||||
email: EmailStr,
|
||||
name: str,
|
||||
members: GroupMembers,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
group = mailbox.add_group(email=email, name=name, members=members)
|
||||
return success_response({"status": "created", "group": format_group(group)})
|
||||
except GroupExistsError as e:
|
||||
return error_response(str(e))
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def edit_group(
|
||||
email: EmailStr,
|
||||
name: str | None = None,
|
||||
members: GroupMembers | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
group = mailbox.edit_group(email=email, name=name, members=members)
|
||||
return success_response({"status": "updated", "group": format_group(group)})
|
||||
except GroupNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def delete_group(email: EmailStr, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
mailbox.delete_group(email)
|
||||
return success_response({"status": "deleted", "email": email})
|
||||
except GroupNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Drafts handlers for Google Mail tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from google_mail.services.mailbox import (
|
||||
DraftNotFoundError,
|
||||
)
|
||||
from google_mail.state import get_mailbox
|
||||
from google_mail.tools.common import (
|
||||
DraftId,
|
||||
MailboxIdArg,
|
||||
PageNumber,
|
||||
PageSize,
|
||||
error_response,
|
||||
format_draft,
|
||||
success_response,
|
||||
)
|
||||
|
||||
|
||||
async def save_draft(
|
||||
subject: str = "",
|
||||
body: str = "",
|
||||
html_body: str | None = None,
|
||||
to: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
draft = mailbox.save_draft(
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
to=to,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
)
|
||||
return success_response({"status": "saved", "draft": format_draft(draft)})
|
||||
except ValidationError as e:
|
||||
return error_response(str(e), status="failed")
|
||||
|
||||
|
||||
async def get_drafts(
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
drafts, total = mailbox.get_drafts(page=page, page_size=page_size)
|
||||
return success_response(
|
||||
{
|
||||
"drafts": [format_draft(d) for d in drafts],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def update_draft(
|
||||
draft_id: DraftId,
|
||||
subject: str | None = None,
|
||||
body: str | None = None,
|
||||
html_body: str | None = None,
|
||||
to: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
draft = mailbox.update_draft(
|
||||
draft_id=draft_id,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
to=to,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
)
|
||||
return success_response({"status": "updated", "draft": format_draft(draft)})
|
||||
except DraftNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
except ValidationError as e:
|
||||
return error_response(str(e), status="failed")
|
||||
|
||||
|
||||
async def delete_draft(draft_id: DraftId, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
mailbox.delete_draft(draft_id)
|
||||
return success_response({"status": "deleted", "draft_id": draft_id})
|
||||
except DraftNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Folders handlers for Google Mail tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google_mail.services.mailbox import (
|
||||
FolderExistsError,
|
||||
FolderNotFoundError,
|
||||
SystemFolderError,
|
||||
)
|
||||
from google_mail.state import get_mailbox
|
||||
from google_mail.tools.common import (
|
||||
FolderName,
|
||||
MailboxIdArg,
|
||||
error_response,
|
||||
success_response,
|
||||
)
|
||||
|
||||
|
||||
async def get_folders(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
folders = mailbox.get_folders()
|
||||
return success_response({"folders": folders})
|
||||
|
||||
|
||||
async def create_folder(folder_name: FolderName, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
mailbox.create_folder(folder_name)
|
||||
return success_response({"status": "created", "folder_name": folder_name})
|
||||
except FolderExistsError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def delete_folder(folder_name: FolderName, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
mailbox.delete_folder(folder_name)
|
||||
return success_response({"status": "deleted", "folder_name": folder_name})
|
||||
except (SystemFolderError, FolderNotFoundError) as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Messages handlers for Google Mail tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from google_mail.models import Attachment
|
||||
from google_mail.services.mailbox import (
|
||||
AttachmentNotFoundError,
|
||||
EmailNotFoundError,
|
||||
FolderNotFoundError,
|
||||
RecipientNotFoundError,
|
||||
RecipientRequiredError,
|
||||
normalize_search_pagination,
|
||||
search_query_warnings,
|
||||
)
|
||||
from google_mail.state import get_mailbox, get_mailboxes
|
||||
from google_mail.tools.common import (
|
||||
AttachmentFilename,
|
||||
AttachmentPaths,
|
||||
EmailId,
|
||||
EmailIds,
|
||||
FolderName,
|
||||
MailboxIdArg,
|
||||
PageNumber,
|
||||
PageSize,
|
||||
batch_response,
|
||||
error_response,
|
||||
format_email_full,
|
||||
format_email_summary,
|
||||
success_response,
|
||||
)
|
||||
|
||||
|
||||
async def list_mailboxes() -> str:
|
||||
result = []
|
||||
for mid, svc in get_mailboxes().items():
|
||||
result.append(
|
||||
{
|
||||
"mailbox_id": mid,
|
||||
"email": svc.data.mailbox.email,
|
||||
"name": svc.data.mailbox.name,
|
||||
"email_count": len(svc.data.emails),
|
||||
"contact_count": len(svc.data.contacts),
|
||||
}
|
||||
)
|
||||
return success_response({"mailboxes": result, "total": len(result)})
|
||||
|
||||
|
||||
async def get_emails(
|
||||
folder: FolderName | None = None,
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
emails, total = mailbox.get_emails(
|
||||
folder=folder,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return success_response(
|
||||
{
|
||||
"emails": [format_email_summary(e) for e in emails],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
except FolderNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def read_email(email_id: EmailId, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
email = mailbox.read_email(email_id)
|
||||
return success_response({"email": format_email_full(email)})
|
||||
except EmailNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def search_emails(
|
||||
query: str,
|
||||
folder: FolderName | None = None,
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
page, page_size, pagination_warnings = normalize_search_pagination(page, page_size)
|
||||
warnings = search_query_warnings(query) + pagination_warnings
|
||||
emails, total = mailbox.search_emails(
|
||||
query=query,
|
||||
folder=folder,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return success_response(
|
||||
{
|
||||
"emails": [format_email_summary(e) for e in emails],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def send_email(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
attachments: AttachmentPaths | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
|
||||
# Process attachments from file paths
|
||||
attachment_objs: list[Attachment] | None = None
|
||||
if attachments:
|
||||
attachment_objs = []
|
||||
for file_path_str in attachments:
|
||||
file_path = Path(file_path_str)
|
||||
if not file_path.exists():
|
||||
return error_response(f"Attachment file not found: {file_path_str}")
|
||||
content = file_path.read_bytes()
|
||||
content_base64 = base64.b64encode(content).decode("utf-8")
|
||||
content_type, _ = mimetypes.guess_type(file_path.name)
|
||||
if content_type is None:
|
||||
content_type = "application/octet-stream"
|
||||
attachment_objs.append(
|
||||
Attachment(
|
||||
filename=file_path.name,
|
||||
content_type=content_type,
|
||||
content_base64=content_base64,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
email = mailbox.send_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
attachments=attachment_objs,
|
||||
)
|
||||
return success_response({"status": "sent", "email": format_email_summary(email)})
|
||||
except RecipientNotFoundError as e:
|
||||
return error_response(
|
||||
f"Invalid recipient: {e.recipient}",
|
||||
status="bounced",
|
||||
message="Recipient not found in contacts. Check available contacts with mail_get_contacts.",
|
||||
)
|
||||
except RecipientRequiredError as e:
|
||||
return error_response(str(e), status="bounced")
|
||||
|
||||
|
||||
async def reply_email(
|
||||
email_id: EmailId,
|
||||
body: str,
|
||||
html_body: str | None = None,
|
||||
reply_all: bool = False,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
email = mailbox.reply_email(
|
||||
email_id=email_id,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
reply_all=reply_all,
|
||||
)
|
||||
return success_response({"status": "sent", "email": format_email_summary(email)})
|
||||
except EmailNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
except RecipientNotFoundError as e:
|
||||
return error_response(f"Invalid recipient: {e.recipient}", status="bounced")
|
||||
|
||||
|
||||
async def forward_email(
|
||||
email_id: EmailId,
|
||||
to: str,
|
||||
body: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
email = mailbox.forward_email(
|
||||
email_id=email_id,
|
||||
to=to,
|
||||
body=body,
|
||||
)
|
||||
return success_response({"status": "sent", "email": format_email_summary(email)})
|
||||
except EmailNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
except RecipientNotFoundError as e:
|
||||
return error_response(f"Invalid recipient: {e.recipient}", status="bounced")
|
||||
|
||||
|
||||
async def delete_emails(
|
||||
email_ids: EmailIds,
|
||||
permanent: bool = False,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
action = "permanently deleted" if permanent else "moved to Trash"
|
||||
result = mailbox.delete_emails(email_ids, permanent=permanent)
|
||||
deleted = result.succeeded_ids
|
||||
return batch_response(
|
||||
action=action,
|
||||
requested_count=len(email_ids),
|
||||
succeeded_ids=deleted,
|
||||
errors=[error.to_dict() for error in result.errors],
|
||||
extra={
|
||||
"deletedCount": len(deleted),
|
||||
"deletedIds": deleted,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def move_emails(
|
||||
email_ids: EmailIds,
|
||||
target_folder: FolderName,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
result = mailbox.move_emails(email_ids, target_folder)
|
||||
moved = result.succeeded_ids
|
||||
return batch_response(
|
||||
action="moved",
|
||||
requested_count=len(email_ids),
|
||||
succeeded_ids=moved,
|
||||
errors=[error.to_dict() for error in result.errors],
|
||||
extra={
|
||||
"target_folder": target_folder,
|
||||
"movedCount": len(moved),
|
||||
"movedIds": moved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def mark_emails(
|
||||
email_ids: EmailIds,
|
||||
is_read: bool | None = None,
|
||||
is_important: bool | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
if is_read is None and is_important is None:
|
||||
return error_response("At least one of is_read or is_important must be provided", status="failed")
|
||||
|
||||
result = mailbox.mark_emails(
|
||||
email_ids=email_ids,
|
||||
is_read=is_read,
|
||||
is_important=is_important,
|
||||
)
|
||||
marked = result.succeeded_ids
|
||||
return batch_response(
|
||||
action="marked",
|
||||
requested_count=len(email_ids),
|
||||
succeeded_ids=marked,
|
||||
errors=[error.to_dict() for error in result.errors],
|
||||
extra={
|
||||
"markedCount": len(marked),
|
||||
"markedIds": marked,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def get_unread_count(folder: FolderName | None = None, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
counts = mailbox.get_unread_count(folder=folder)
|
||||
return success_response({"unread_counts": counts})
|
||||
except FolderNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
async def get_mailbox_stats(mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
stats = mailbox.get_mailbox_stats()
|
||||
return success_response(stats)
|
||||
|
||||
|
||||
async def download_attachment(
|
||||
email_id: EmailId,
|
||||
filename: AttachmentFilename,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
attachment = mailbox.get_attachment(email_id, filename)
|
||||
return success_response(
|
||||
{
|
||||
"filename": attachment.filename,
|
||||
"content_type": attachment.content_type,
|
||||
"size": attachment.size,
|
||||
"content_base64": attachment.content_base64,
|
||||
}
|
||||
)
|
||||
except EmailNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
except AttachmentNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Scheduling handlers for Google Mail tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google_mail.services.mailbox import (
|
||||
EmailNotFoundError,
|
||||
RecipientNotFoundError,
|
||||
RecipientRequiredError,
|
||||
)
|
||||
from google_mail.state import get_mailbox
|
||||
from google_mail.tools.common import (
|
||||
EmailId,
|
||||
MailboxIdArg,
|
||||
PageNumber,
|
||||
PageSize,
|
||||
ScheduleTime,
|
||||
error_response,
|
||||
format_email_summary,
|
||||
normalize_scheduled_time,
|
||||
success_response,
|
||||
)
|
||||
|
||||
|
||||
async def schedule_email(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
scheduled_time: ScheduleTime,
|
||||
html_body: str | None = None,
|
||||
cc: str | None = None,
|
||||
bcc: str | None = None,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
send_at = normalize_scheduled_time(scheduled_time)
|
||||
except ValueError:
|
||||
return error_response(f"Invalid scheduled_time format: {scheduled_time}")
|
||||
|
||||
try:
|
||||
email = mailbox.schedule_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
scheduled_time=send_at,
|
||||
html_body=html_body,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
)
|
||||
result = format_email_summary(email)
|
||||
result["scheduled_time"] = email.scheduled_time.isoformat() if email.scheduled_time else None
|
||||
return success_response({"status": "scheduled", "email": result})
|
||||
except RecipientNotFoundError as e:
|
||||
return error_response(str(e), status="failed")
|
||||
except RecipientRequiredError as e:
|
||||
return error_response(str(e), status="failed")
|
||||
|
||||
|
||||
async def get_scheduled_emails(
|
||||
page: PageNumber = 1,
|
||||
page_size: PageSize = 20,
|
||||
mailbox_id: MailboxIdArg = "default",
|
||||
) -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
emails, total = mailbox.get_scheduled_emails(page=page, page_size=page_size)
|
||||
results = []
|
||||
for e in emails:
|
||||
r = format_email_summary(e)
|
||||
r["scheduled_time"] = e.scheduled_time.isoformat() if e.scheduled_time else None
|
||||
results.append(r)
|
||||
return success_response(
|
||||
{
|
||||
"scheduled_emails": results,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def cancel_scheduled_email(email_id: EmailId, mailbox_id: MailboxIdArg = "default") -> str:
|
||||
mailbox = get_mailbox(mailbox_id)
|
||||
try:
|
||||
mailbox.cancel_scheduled_email(email_id)
|
||||
return success_response({"status": "cancelled", "email_id": email_id})
|
||||
except EmailNotFoundError as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,15 @@
|
||||
"""State handlers for Google Mail tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google_mail.models import GoogleMailState
|
||||
from google_mail.state import export_state_model, state_from_json
|
||||
|
||||
|
||||
async def export_state() -> GoogleMailState:
|
||||
return export_state_model()
|
||||
|
||||
|
||||
async def import_state(state: GoogleMailState) -> dict:
|
||||
state_from_json(state)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Mail viewer — read-only webmail UI and API endpoints.
|
||||
|
||||
Serves:
|
||||
GET /api/folders — folder list with counts
|
||||
GET /api/emails — email list (optional ?folder=X&search=X&page=N)
|
||||
GET /api/emails/:id — single email detail
|
||||
GET /api/contacts — contact list
|
||||
GET /api/stats — mailbox stats
|
||||
GET / — viewer HTML (single-page app)
|
||||
|
||||
All non-MCP routes require the X-Proxy-Token header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProxyTokenMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Skip auth for MCP endpoint
|
||||
if request.url.path.startswith("/mcp"):
|
||||
return await call_next(request)
|
||||
token = os.environ.get("MCP_PROXY_TOKEN", "")
|
||||
if token and request.headers.get("x-proxy-token") != token:
|
||||
return Response("Forbidden: invalid proxy token", status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy access to the mailbox service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_mailbox_ref = None
|
||||
|
||||
|
||||
def set_mailbox(mailbox) -> None:
|
||||
"""Set the mailbox instance for the viewer to use."""
|
||||
global _mailbox_ref
|
||||
_mailbox_ref = mailbox
|
||||
|
||||
|
||||
def _get_mailbox(request: Request | None = None):
|
||||
"""Return the mailbox service, initializing state if needed.
|
||||
|
||||
If the MCP server has not initialized yet, initialize the mailbox eagerly
|
||||
from the same state loader used by the server.
|
||||
"""
|
||||
if _mailbox_ref is not None:
|
||||
return _mailbox_ref
|
||||
|
||||
from google_mail.state import get_mailboxes, init_state
|
||||
|
||||
init_state()
|
||||
mailbox_id = request.query_params.get("mailbox_id") if request is not None else None
|
||||
mailboxes = get_mailboxes()
|
||||
if mailbox_id:
|
||||
if mailbox_id not in mailboxes:
|
||||
available = ", ".join(sorted(mailboxes.keys()))
|
||||
raise KeyError(f"Mailbox '{mailbox_id}' not found. Available: {available}")
|
||||
return mailboxes[mailbox_id]
|
||||
if "default" in mailboxes:
|
||||
return mailboxes["default"]
|
||||
first_id = sorted(mailboxes.keys())[0]
|
||||
return mailboxes[first_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def api_folders(request: Request) -> JSONResponse:
|
||||
try:
|
||||
mailbox = _get_mailbox(request)
|
||||
except KeyError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||||
folders = mailbox.get_folders()
|
||||
return JSONResponse({"folders": folders})
|
||||
|
||||
|
||||
async def api_emails(request: Request) -> JSONResponse:
|
||||
try:
|
||||
mailbox = _get_mailbox(request)
|
||||
except KeyError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||||
folder = request.query_params.get("folder")
|
||||
search = request.query_params.get("search")
|
||||
page = int(request.query_params.get("page", "1"))
|
||||
page_size = int(request.query_params.get("page_size", "50"))
|
||||
|
||||
if search:
|
||||
emails, total = mailbox.search_emails(query=search, folder=folder, page=page, page_size=page_size)
|
||||
else:
|
||||
emails, total = mailbox.get_emails(folder=folder, page=page, page_size=page_size)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"emails": [_format_summary(e) for e in emails],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_email_detail(request: Request) -> JSONResponse:
|
||||
try:
|
||||
mailbox = _get_mailbox(request)
|
||||
except KeyError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||||
email_id = request.path_params["email_id"]
|
||||
try:
|
||||
email = mailbox.read_email(email_id)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Email not found"}, status_code=404)
|
||||
|
||||
return JSONResponse({"email": _format_full(email)})
|
||||
|
||||
|
||||
async def api_contacts(request: Request) -> JSONResponse:
|
||||
try:
|
||||
mailbox = _get_mailbox(request)
|
||||
except KeyError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||||
contacts = mailbox.get_contacts()
|
||||
groups = mailbox.get_groups()
|
||||
return JSONResponse(
|
||||
{
|
||||
"contacts": [{"email": c.email, "name": c.name} for c in contacts],
|
||||
"groups": [{"email": g.email, "name": g.name, "members": g.members} for g in groups],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_stats(request: Request) -> JSONResponse:
|
||||
try:
|
||||
mailbox = _get_mailbox(request)
|
||||
except KeyError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||||
stats = mailbox.get_mailbox_stats()
|
||||
return JSONResponse(stats)
|
||||
|
||||
|
||||
async def viewer_html(request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(VIEWER_HTML)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_summary(email) -> dict[str, Any]:
|
||||
return {
|
||||
"email_id": email.email_id,
|
||||
"folder": email.folder,
|
||||
"subject": email.subject,
|
||||
"from_addr": email.from_addr,
|
||||
"to_addr": email.to_addr,
|
||||
"date": email.date.isoformat(),
|
||||
"is_read": email.is_read,
|
||||
"is_important": email.is_important,
|
||||
"has_attachments": len(email.attachments) > 0,
|
||||
}
|
||||
|
||||
|
||||
def _format_full(email) -> dict[str, Any]:
|
||||
d = _format_summary(email)
|
||||
d["cc_addr"] = email.cc_addr
|
||||
d["bcc_addr"] = email.bcc_addr
|
||||
d["body_text"] = email.body_text
|
||||
d["body_html"] = email.body_html
|
||||
d["attachments"] = [
|
||||
{"filename": a.filename, "content_type": a.content_type, "size": a.size} for a in email.attachments
|
||||
]
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_mail_viewer_app():
|
||||
routes = [
|
||||
Route("/", viewer_html),
|
||||
Route("/api/folders", api_folders),
|
||||
Route("/api/emails", api_emails),
|
||||
Route("/api/emails/{email_id}", api_email_detail),
|
||||
Route("/api/contacts", api_contacts),
|
||||
Route("/api/stats", api_stats),
|
||||
]
|
||||
return Starlette(
|
||||
routes=routes,
|
||||
middleware=[Middleware(ProxyTokenMiddleware)],
|
||||
)
|
||||
|
||||
|
||||
def run_http_server(mcp_app, port: int) -> None:
|
||||
"""Run combined MCP + viewer HTTP server."""
|
||||
|
||||
# Get the ASGI app from FastMCP
|
||||
# mcp.server.fastmcp.FastMCP uses streamable_http_app(); fastmcp.FastMCP uses http_app()
|
||||
if hasattr(mcp_app, "streamable_http_app"):
|
||||
fastmcp_asgi = mcp_app.streamable_http_app()
|
||||
else:
|
||||
fastmcp_asgi = mcp_app.http_app(
|
||||
transport="streamable-http",
|
||||
path="/mcp",
|
||||
)
|
||||
|
||||
viewer = create_mail_viewer_app()
|
||||
|
||||
# Combined app: route /mcp to FastMCP, everything else to viewer
|
||||
async def combined_app(scope, receive, send):
|
||||
if scope["type"] == "lifespan":
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/mcp"):
|
||||
await fastmcp_asgi(scope, receive, send)
|
||||
else:
|
||||
await viewer(scope, receive, send)
|
||||
|
||||
uvicorn.run(
|
||||
combined_app,
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="warning",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Viewer HTML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VIEWER_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mail</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; color: #1e293b; display: flex; height: 100vh; }
|
||||
|
||||
/* Folder sidebar */
|
||||
.folders { width: 200px; min-width: 200px; background: #fff; border-right: 1px solid #e2e8f0; display: flex; flex-direction: column; }
|
||||
.folders-header { padding: 16px; border-bottom: 1px solid #e2e8f0; }
|
||||
.folders-header h2 { font-size: 15px; font-weight: 600; }
|
||||
.folder-list { flex: 1; padding: 8px; overflow-y: auto; }
|
||||
.folder-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #475569; margin-bottom: 2px; }
|
||||
.folder-item:hover { background: #f1f5f9; }
|
||||
.folder-item.active { background: #eff6ff; color: #2563eb; font-weight: 600; }
|
||||
.folder-item .count { font-size: 11px; color: #94a3b8; }
|
||||
.folder-item.active .count { color: #60a5fa; }
|
||||
|
||||
/* Email list */
|
||||
.email-list-pane { width: 360px; min-width: 360px; border-right: 1px solid #e2e8f0; display: flex; flex-direction: column; background: #fff; }
|
||||
.list-header { padding: 10px 16px; border-bottom: 1px solid #e2e8f0; }
|
||||
.list-header input { width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; }
|
||||
.list-header input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
|
||||
.email-list { flex: 1; overflow-y: auto; }
|
||||
.email-item { padding: 12px 16px; border-bottom: 1px solid #f1f5f9; cursor: pointer; }
|
||||
.email-item:hover { background: #f8fafc; }
|
||||
.email-item.active { background: #eff6ff; }
|
||||
.email-item.unread { border-left: 3px solid #3b82f6; }
|
||||
.email-item .from { font-size: 13px; font-weight: 600; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.email-item.unread .from { color: #0f172a; }
|
||||
.email-item .subject { font-size: 13px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
|
||||
.email-item .preview { font-size: 12px; color: #94a3b8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
|
||||
.email-item .meta { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; }
|
||||
.email-item .date { font-size: 11px; color: #94a3b8; }
|
||||
.email-item .badges { display: flex; gap: 4px; }
|
||||
.badge-important { color: #f59e0b; font-size: 12px; }
|
||||
.badge-attachment { color: #64748b; font-size: 12px; }
|
||||
|
||||
/* Detail pane */
|
||||
.detail-pane { flex: 1; display: flex; flex-direction: column; background: #fff; overflow-y: auto; }
|
||||
.detail-header { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; }
|
||||
.detail-header h2 { font-size: 18px; font-weight: 600; line-height: 1.4; }
|
||||
.detail-meta { margin-top: 12px; }
|
||||
.detail-meta .row { display: flex; gap: 8px; font-size: 13px; margin-bottom: 4px; }
|
||||
.detail-meta .label { color: #64748b; min-width: 40px; }
|
||||
.detail-meta .value { color: #1e293b; }
|
||||
.detail-body { padding: 24px; flex: 1; }
|
||||
.detail-body .body-text { font-size: 14px; line-height: 1.7; white-space: pre-wrap; color: #334155; }
|
||||
.detail-attachments { padding: 0 24px 24px; }
|
||||
.detail-attachments h4 { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.attachment-item { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #f1f5f9; border-radius: 6px; font-size: 12px; margin-right: 8px; margin-bottom: 4px; }
|
||||
|
||||
.empty { text-align: center; padding: 60px; color: #94a3b8; font-size: 14px; }
|
||||
.detail-empty { display: flex; align-items: center; justify-content: center; flex: 1; color: #94a3b8; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="folders">
|
||||
<div class="folders-header"><h2>Mail</h2></div>
|
||||
<div id="folder-list" class="folder-list"></div>
|
||||
</div>
|
||||
<div class="email-list-pane">
|
||||
<div class="list-header">
|
||||
<input type="text" id="search-input" placeholder="Search emails..." oninput="onSearch()">
|
||||
</div>
|
||||
<div id="email-list" class="email-list">
|
||||
<div class="empty">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detail-pane" class="detail-pane">
|
||||
<div class="detail-empty">Select an email to read</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentFolder = null;
|
||||
let emails = [];
|
||||
let searchTimeout = null;
|
||||
const base = window.location.pathname.replace(/\\/$/, '');
|
||||
|
||||
async function fetchJSON(path) {
|
||||
const r = await fetch(base + path);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
async function init() {
|
||||
const data = await fetchJSON('/api/folders');
|
||||
const el = document.getElementById('folder-list');
|
||||
el.innerHTML = data.folders.map(f =>
|
||||
'<div class="folder-item" data-folder="' + esc(f.name) + '" onclick="selectFolder(\\'' + esc(f.name) + '\\')">' +
|
||||
'<span>' + folderIcon(f.name) + ' ' + esc(f.name) + '</span>' +
|
||||
'<span class="count">' + (f.unread || '') + '</span>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
if (data.folders.length) selectFolder('INBOX');
|
||||
}
|
||||
|
||||
function folderIcon(name) {
|
||||
const icons = { INBOX: '📥', Sent: '📤', Drafts: '📝', Trash: '🗑️' };
|
||||
return icons[name] || '📁';
|
||||
}
|
||||
|
||||
async function selectFolder(folder) {
|
||||
currentFolder = folder;
|
||||
document.querySelectorAll('.folder-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.folder === folder);
|
||||
});
|
||||
await loadEmails();
|
||||
}
|
||||
|
||||
async function loadEmails() {
|
||||
const search = document.getElementById('search-input').value;
|
||||
let q = '/api/emails?page_size=100';
|
||||
if (currentFolder) q += '&folder=' + encodeURIComponent(currentFolder);
|
||||
if (search) q += '&search=' + encodeURIComponent(search);
|
||||
const data = await fetchJSON(q);
|
||||
emails = data.emails;
|
||||
renderList();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('email-list');
|
||||
if (!emails.length) {
|
||||
el.innerHTML = '<div class="empty">No emails</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = emails.map(e => {
|
||||
const unread = !e.is_read ? ' unread' : '';
|
||||
return '<div class="email-item' + unread + '" onclick="showEmail(\\'' + e.email_id + '\\')">' +
|
||||
'<div class="from">' + esc(e.from_addr) + '</div>' +
|
||||
'<div class="subject">' + esc(e.subject || '(no subject)') + '</div>' +
|
||||
'<div class="meta">' +
|
||||
'<span class="date">' + formatDate(e.date) + '</span>' +
|
||||
'<span class="badges">' +
|
||||
(e.is_important ? '<span class="badge-important">★</span>' : '') +
|
||||
(e.has_attachments ? '<span class="badge-attachment">📎</span>' : '') +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function showEmail(id) {
|
||||
const data = await fetchJSON('/api/emails/' + id);
|
||||
const e = data.email;
|
||||
document.querySelectorAll('.email-item').forEach(el => el.classList.remove('active'));
|
||||
// Mark active - find by looking at onclick
|
||||
document.querySelectorAll('.email-item').forEach(el => {
|
||||
if (el.getAttribute('onclick')?.includes(id)) el.classList.add('active');
|
||||
});
|
||||
|
||||
let html = '<div class="detail-header"><h2>' + esc(e.subject || '(no subject)') + '</h2>';
|
||||
html += '<div class="detail-meta">';
|
||||
html += '<div class="row"><span class="label">From</span><span class="value">' + esc(e.from_addr) + '</span></div>';
|
||||
html += '<div class="row"><span class="label">To</span><span class="value">' + esc(e.to_addr) + '</span></div>';
|
||||
if (e.cc_addr) html += '<div class="row"><span class="label">CC</span><span class="value">' + esc(e.cc_addr) + '</span></div>';
|
||||
html += '<div class="row"><span class="label">Date</span><span class="value">' + formatDate(e.date) + '</span></div>';
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="detail-body"><div class="body-text">' + esc(e.body_text) + '</div></div>';
|
||||
|
||||
if (e.attachments && e.attachments.length) {
|
||||
html += '<div class="detail-attachments"><h4>Attachments</h4>';
|
||||
e.attachments.forEach(a => {
|
||||
html += '<span class="attachment-item">📎 ' + esc(a.filename) + ' (' + formatSize(a.size) + ')</span>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
document.getElementById('detail-pane').innerHTML = html;
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(loadEmails, 300);
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
try {
|
||||
const dt = new Date(d);
|
||||
const now = new Date();
|
||||
if (dt.toDateString() === now.toDateString()) {
|
||||
return dt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch { return d; }
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for google_mail."""
|
||||
@@ -0,0 +1,365 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_mail_state():
|
||||
import google_mail.state as state
|
||||
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
yield
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
def _write_mailbox(path):
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "t@t.com", "name": "T"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_inputdir_loads_first_json(monkeypatch, tmp_path):
|
||||
"""When INPUTDIR is set, the server loads the first .json in that dir."""
|
||||
data_file = tmp_path / "google_mail.json"
|
||||
_write_mailbox(data_file)
|
||||
monkeypatch.setenv("INPUTDIR", str(tmp_path))
|
||||
|
||||
json_files = sorted(Path(str(tmp_path)).glob("*.json"))
|
||||
assert json_files[0] == data_file
|
||||
|
||||
|
||||
def test_inputdir_loads_only_first_file_not_merged(monkeypatch, tmp_path):
|
||||
"""Legacy contract: INPUTDIR loads ONLY the first *.json (alphabetically),
|
||||
not a merge of all of them — matching scripts/validate_external_services.py.
|
||||
Folder coalescing is a bundle-only behavior."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
inputdir = tmp_path / "in"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "a.json").write_text(
|
||||
json.dumps({"mailbox": {"email": "first@t.com", "name": "First"}, "emails": [], "next_email_id": 1})
|
||||
)
|
||||
(inputdir / "b.json").write_text(
|
||||
json.dumps({"mailbox": {"email": "second@t.com", "name": "Second"}, "emails": [], "next_email_id": 1})
|
||||
)
|
||||
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
state.set_mailboxes({})
|
||||
|
||||
srv.init_state()
|
||||
|
||||
mailboxes = state.get_mailboxes()
|
||||
assert set(mailboxes) == {"default"}
|
||||
assert mailboxes["default"].data.mailbox.email == "first@t.com"
|
||||
|
||||
|
||||
def test_inputdir_empty_uses_default(monkeypatch, tmp_path):
|
||||
"""When INPUTDIR is set but empty, server starts with an empty default mailbox."""
|
||||
monkeypatch.setenv("INPUTDIR", str(tmp_path))
|
||||
|
||||
json_files = sorted(Path(str(tmp_path)).glob("*.json"))
|
||||
assert json_files == []
|
||||
|
||||
|
||||
def test_inputdir_not_set_uses_default(monkeypatch):
|
||||
"""When INPUTDIR is not set, server starts with an empty default mailbox (no error)."""
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
import os
|
||||
|
||||
assert os.environ.get("INPUTDIR") is None
|
||||
# Server should start cleanly — no error raised
|
||||
|
||||
|
||||
def test_bundle_state_json_preferred_over_inputdir(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR is set and $BUNDLEDIR/services/google_mail/state.json
|
||||
exists, init_state loads it instead of any legacy *.json files
|
||||
in INPUTDIR."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
bundle_root = tmp_path / "bundle"
|
||||
service_dir = bundle_root / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "bundle@t.com", "name": "Bundle"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
inputdir = tmp_path / "legacy"
|
||||
inputdir.mkdir()
|
||||
_write_mailbox(inputdir / "legacy.json")
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_root))
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "bundle@t.com"
|
||||
|
||||
|
||||
def test_bundle_glob_when_state_json_absent(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR carries a per-service subdir with a named JSON (e.g.
|
||||
the preserved entities-zip layout) but no state.json, the loader reads
|
||||
it from that subdir."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
bundle_root = tmp_path / "bundle"
|
||||
service_dir = bundle_root / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "inbox.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "preserved@t.com", "name": "Preserved"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_root))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "preserved@t.com"
|
||||
|
||||
|
||||
def test_falls_back_to_inputdir_when_bundle_service_dir_missing(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR is set but doesn't carry this service's slice,
|
||||
init_state falls back to the legacy INPUTDIR/*.json glob — a partial
|
||||
bundle doesn't strand a service."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
# BUNDLEDIR points at a real dir, but no google_mail/ subdir under it.
|
||||
bundle_root = tmp_path / "bundle"
|
||||
(bundle_root / "services").mkdir(parents=True)
|
||||
|
||||
inputdir = tmp_path / "legacy"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "legacy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "legacy@t.com", "name": "Legacy"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_root))
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "legacy@t.com"
|
||||
|
||||
|
||||
def test_falls_back_to_inputdir_when_bundledir_unset(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR is unset entirely, init_state uses INPUTDIR (the
|
||||
common local-dev path). The bundle code path is skipped."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
inputdir = tmp_path / "legacy"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "legacy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "legacy@t.com", "name": "Legacy"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "legacy@t.com"
|
||||
|
||||
|
||||
def test_init_state_configures_snapshot_paths_when_registry_preloaded(monkeypatch, tmp_path):
|
||||
"""Preloaded state should still pick up env-configured snapshot paths."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
|
||||
data_file = tmp_path / "mailbox.json"
|
||||
_write_mailbox(data_file)
|
||||
svc = MailboxService(data_file)
|
||||
svc.load()
|
||||
|
||||
outputdir = tmp_path / "output"
|
||||
bundle_output_dir = tmp_path / "services" / "google_mail"
|
||||
state.set_mailboxes({"default": svc})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
monkeypatch.setenv("OUTPUTDIR", str(outputdir))
|
||||
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", str(bundle_output_dir))
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_final_path() == outputdir / "final.json"
|
||||
assert state.get_bundle_state_path() == bundle_output_dir / "state.json"
|
||||
|
||||
|
||||
def _write_mailbox_email(path, email):
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": email, "name": email.split("@")[0]},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _email_record(email_id, subject):
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"folder": "INBOX",
|
||||
"subject": subject,
|
||||
"from_addr": "sender@t.com",
|
||||
"to_addr": "agent@t.com",
|
||||
"date": "2026-01-01T00:00:00+00:00",
|
||||
"message_id": f"<{email_id}@t.com>",
|
||||
"body_text": subject,
|
||||
"is_read": False,
|
||||
}
|
||||
|
||||
|
||||
def test_bundle_multifile_flat_files_merge_into_one_mailbox(monkeypatch, tmp_path):
|
||||
"""the raw entities layout splits ONE mailbox across per-entity files
|
||||
(e.g. inbox.json + sent.json). Flat files without a {mailboxes} wrapper
|
||||
coalesce into a single default mailbox — entities combined, not fragmented
|
||||
into separate mailboxes."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
service_dir = tmp_path / "bundle" / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
# Two halves of the SAME mailbox: identity + first email; second email.
|
||||
(service_dir / "a.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "agent@t.com", "name": "Agent"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [_email_record("1", "first")],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
)
|
||||
(service_dir / "b.json").write_text(json.dumps({"emails": [_email_record("2", "second")], "next_email_id": 3}))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "bundle"))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
state.set_mailboxes({})
|
||||
srv.init_state()
|
||||
|
||||
mailboxes = state.get_mailboxes()
|
||||
assert set(mailboxes) == {"default"}, "flat per-entity files must merge into ONE mailbox"
|
||||
data = mailboxes["default"].data
|
||||
assert data.mailbox.email == "agent@t.com"
|
||||
subjects = {e.subject for e in data.emails}
|
||||
assert subjects == {"first", "second"}, "both per-entity files' emails must be present"
|
||||
assert data.next_email_id == 3
|
||||
|
||||
|
||||
def test_bundle_mailboxes_wrapper_yields_named_mailboxes(monkeypatch, tmp_path):
|
||||
"""An explicit {mailboxes: {...}} wrapper still produces multiple named
|
||||
mailboxes — multi-tenant worlds opt in via the wrapper, not via separate
|
||||
flat files."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
service_dir = tmp_path / "bundle" / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailboxes": {
|
||||
"alice": {"mailbox": {"email": "a@t.com", "name": "A"}, "emails": [], "next_email_id": 1},
|
||||
"bob": {"mailbox": {"email": "b@t.com", "name": "B"}, "emails": [], "next_email_id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "bundle"))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
state.set_mailboxes({})
|
||||
srv.init_state()
|
||||
|
||||
mailboxes = state.get_mailboxes()
|
||||
assert set(mailboxes) == {"alice", "bob"}
|
||||
assert {svc.data.mailbox.email for svc in mailboxes.values()} == {"a@t.com", "b@t.com"}
|
||||
|
||||
|
||||
def test_resolve_bundle_state_paths_returns_whole_folder(monkeypatch, tmp_path):
|
||||
"""The plural resolver returns ALL sorted *.json when there's no state.json,
|
||||
and exactly [state.json] when one is present."""
|
||||
import google_mail.state as state
|
||||
|
||||
service_dir = tmp_path / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
a_json = service_dir / "a.json"
|
||||
b_json = service_dir / "b.json"
|
||||
_write_mailbox_email(a_json, "a@t.com")
|
||||
_write_mailbox_email(b_json, "b@t.com")
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_paths() == [a_json, b_json]
|
||||
|
||||
state_json = service_dir / "state.json"
|
||||
_write_mailbox_email(state_json, "state@t.com")
|
||||
assert state.resolve_bundle_state_paths() == [state_json]
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Tests for the _snapshot_on_write decorator — final.json is written after every write tool call."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mailbox_service(tmp_path):
|
||||
"""Create a MailboxService seeded with minimal data."""
|
||||
data_path = tmp_path / "mailbox.json"
|
||||
data_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "test@test.com", "name": "Test User"},
|
||||
"contacts": [{"email": "bob@test.com", "name": "Bob"}],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
svc = MailboxService(data_path)
|
||||
svc.load()
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
"""Return a temp directory for OUTPUTDIR, with a pre-configured final.json path."""
|
||||
out = tmp_path / "output" / "google_mail"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_server_globals(mailbox_service, outputdir):
|
||||
"""Wire up state globals so tools and the decorator can run."""
|
||||
import google_mail.state as state
|
||||
|
||||
state.set_mailboxes({"default": mailbox_service})
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json")
|
||||
yield
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
def test_send_email_writes_final_json(outputdir):
|
||||
"""Calling mail_send_email produces a final.json snapshot immediately."""
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists(), "final.json should not exist before any write"
|
||||
|
||||
result = asyncio.run(
|
||||
mail_send_email(
|
||||
to="bob@test.com",
|
||||
subject="snapshot test",
|
||||
body="hello",
|
||||
)
|
||||
)
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "sent"
|
||||
assert final.exists(), "final.json must be written after mail_send_email"
|
||||
|
||||
snapshot = json.loads(final.read_text())
|
||||
subjects = [e["subject"] for e in snapshot.get("emails", [])]
|
||||
assert "snapshot test" in subjects
|
||||
|
||||
|
||||
def test_send_email_without_recipients_returns_error_without_consuming_id(mailbox_service):
|
||||
"""Empty recipient sends return a structured error and leave counters untouched."""
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
next_email_id = mailbox_service.data.next_email_id
|
||||
|
||||
result = asyncio.run(mail_send_email(to="", subject="empty recipient", body="hello"))
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "bounced"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.data.next_email_id == next_email_id
|
||||
|
||||
|
||||
def test_save_draft_invalid_recipient_returns_error_without_consuming_id(mailbox_service):
|
||||
"""Invalid draft recipients return structured errors instead of raising."""
|
||||
from google_mail.server import mail_save_draft
|
||||
|
||||
next_email_id = mailbox_service.data.next_email_id
|
||||
|
||||
result = asyncio.run(mail_save_draft(to="not an email", subject="bad draft", body="hello"))
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "failed"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.data.next_email_id == next_email_id
|
||||
|
||||
|
||||
def test_update_draft_invalid_recipient_returns_error_without_corrupting_draft(mailbox_service):
|
||||
"""Invalid draft updates return structured errors and preserve the existing draft."""
|
||||
from google_mail.server import mail_save_draft, mail_update_draft
|
||||
|
||||
created = json.loads(asyncio.run(mail_save_draft(to="bob@test.com", subject="draft", body="hello")))
|
||||
draft_id = created["draft"]["draft_id"]
|
||||
original = mailbox_service.get_draft(draft_id).model_dump(mode="json")
|
||||
|
||||
result = asyncio.run(mail_update_draft(draft_id=draft_id, to="not an email", subject="bad update"))
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "failed"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.get_draft(draft_id).model_dump(mode="json") == original
|
||||
|
||||
|
||||
def test_delete_email_writes_final_json(outputdir):
|
||||
"""Calling mail_delete_emails produces a final.json snapshot."""
|
||||
from google_mail.server import mail_delete_emails, mail_send_email
|
||||
|
||||
# First, create an email to delete
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-delete", body="bye"))
|
||||
sent = json.loads(result)
|
||||
email_id = sent["email"]["email_id"]
|
||||
|
||||
# Remove existing final.json to isolate the delete's snapshot
|
||||
final = outputdir / "final.json"
|
||||
final.unlink()
|
||||
|
||||
asyncio.run(mail_delete_emails([email_id]))
|
||||
|
||||
assert final.exists(), "final.json must be written after mail_delete_emails"
|
||||
|
||||
|
||||
def test_delete_emails_reports_partial_batch_status():
|
||||
from google_mail.server import mail_delete_emails, mail_send_email
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-delete", body="bye"))
|
||||
email_id = json.loads(result)["email"]["email_id"]
|
||||
|
||||
data = json.loads(asyncio.run(mail_delete_emails([email_id, "missing"])))
|
||||
|
||||
assert data["status"] == "partial_success"
|
||||
assert data["summary"] == "1 of 2 attempts succeeded"
|
||||
assert data["requestedCount"] == 2
|
||||
assert data["succeededCount"] == 1
|
||||
assert data["failedCount"] == 1
|
||||
assert data["deletedIds"] == [email_id]
|
||||
assert data["errors"][0]["email_id"] == "missing"
|
||||
|
||||
|
||||
def test_delete_emails_reports_all_failed_batch_status():
|
||||
from google_mail.server import mail_delete_emails
|
||||
|
||||
data = json.loads(asyncio.run(mail_delete_emails(["missing-1", "missing-2"])))
|
||||
|
||||
assert data["status"] == "all_failed"
|
||||
assert data["summary"] == "All 2 attempts failed"
|
||||
assert data["requestedCount"] == 2
|
||||
assert data["succeededCount"] == 0
|
||||
assert data["failedCount"] == 2
|
||||
|
||||
|
||||
def test_mark_emails_requires_an_action_flag():
|
||||
from google_mail.server import mail_mark_emails, mail_send_email
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-mark", body="hi"))
|
||||
email_id = json.loads(result)["email"]["email_id"]
|
||||
|
||||
data = json.loads(asyncio.run(mail_mark_emails([email_id])))
|
||||
|
||||
assert data["status"] == "failed"
|
||||
assert "At least one" in data["error"]
|
||||
|
||||
|
||||
def test_mark_emails_reports_partial_batch_status():
|
||||
from google_mail.server import mail_mark_emails, mail_send_email
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-mark", body="hi"))
|
||||
email_id = json.loads(result)["email"]["email_id"]
|
||||
|
||||
data = json.loads(asyncio.run(mail_mark_emails([email_id, "missing"], is_read=False)))
|
||||
|
||||
assert data["status"] == "partial_success"
|
||||
assert data["summary"] == "1 of 2 attempts succeeded"
|
||||
assert data["markedCount"] == 1
|
||||
assert data["markedIds"] == [email_id]
|
||||
assert data["errors"][0]["email_id"] == "missing"
|
||||
|
||||
|
||||
def test_create_folder_writes_final_json(outputdir):
|
||||
"""Calling mail_create_folder produces a final.json snapshot."""
|
||||
from google_mail.server import mail_create_folder
|
||||
|
||||
final = outputdir / "final.json"
|
||||
asyncio.run(mail_create_folder("TestFolder"))
|
||||
|
||||
assert final.exists(), "final.json must be written after mail_create_folder"
|
||||
snapshot = json.loads(final.read_text())
|
||||
folder_names = [f["name"] for f in snapshot.get("folders", [])]
|
||||
assert "TestFolder" in folder_names
|
||||
|
||||
|
||||
def test_read_only_tool_does_not_write_final_json(outputdir):
|
||||
"""Calling a read-only tool (mail_get_emails) does NOT write final.json."""
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
final = outputdir / "final.json"
|
||||
asyncio.run(mail_get_emails(folder="INBOX"))
|
||||
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
def test_final_json_updates_incrementally(outputdir):
|
||||
"""Each write tool call overwrites final.json with the latest state."""
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
|
||||
asyncio.run(mail_send_email(to="bob@test.com", subject="first", body="1"))
|
||||
snap1 = json.loads(final.read_text())
|
||||
count1 = len(snap1.get("emails", []))
|
||||
|
||||
asyncio.run(mail_send_email(to="bob@test.com", subject="second", body="2"))
|
||||
snap2 = json.loads(final.read_text())
|
||||
count2 = len(snap2.get("emails", []))
|
||||
|
||||
assert count2 > count1, "final.json should reflect each incremental mutation"
|
||||
|
||||
|
||||
def test_add_contact_writes_final_json(outputdir):
|
||||
"""Calling mail_add_contact produces a final.json snapshot."""
|
||||
from google_mail.server import mail_add_contact
|
||||
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists()
|
||||
|
||||
result = asyncio.run(mail_add_contact(email="alice@test.com", name="Alice"))
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "created"
|
||||
assert final.exists(), "final.json must be written after mail_add_contact"
|
||||
|
||||
|
||||
def test_schedule_email_writes_final_json(outputdir):
|
||||
"""Calling mail_schedule_email produces a final.json snapshot."""
|
||||
from google_mail.server import mail_schedule_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists()
|
||||
|
||||
result = asyncio.run(
|
||||
mail_schedule_email(
|
||||
to="bob@test.com",
|
||||
subject="scheduled test",
|
||||
body="hello",
|
||||
scheduled_time="2025-06-01T09:00:00Z",
|
||||
)
|
||||
)
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "scheduled"
|
||||
assert final.exists(), "final.json must be written after mail_schedule_email"
|
||||
|
||||
|
||||
def test_schedule_email_without_recipients_returns_error_without_consuming_id(mailbox_service):
|
||||
"""Empty scheduled sends return a structured error and leave counters untouched."""
|
||||
from google_mail.server import mail_schedule_email
|
||||
|
||||
next_email_id = mailbox_service.data.next_email_id
|
||||
|
||||
result = asyncio.run(
|
||||
mail_schedule_email(
|
||||
to="",
|
||||
subject="empty recipient",
|
||||
body="hello",
|
||||
scheduled_time="2025-06-01T09:00:00Z",
|
||||
)
|
||||
)
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "failed"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.data.next_email_id == next_email_id
|
||||
|
||||
|
||||
def test_get_contacts_does_not_write_final_json(outputdir):
|
||||
"""Calling a read-only contact tool does NOT write final.json."""
|
||||
from google_mail.server import mail_get_contacts
|
||||
|
||||
final = outputdir / "final.json"
|
||||
asyncio.run(mail_get_contacts())
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
def test_no_final_path_skips_snapshot():
|
||||
"""When _final_path is None (no OUTPUTDIR), write tools still work without error."""
|
||||
import google_mail.state as state
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="no-outputdir", body="ok"))
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "sent"
|
||||
|
||||
|
||||
def test_dual_writes_bundle_path_and_final_json(outputdir, tmp_path):
|
||||
"""Bundle migration: writable tools dual-write the bundle path (nested
|
||||
services/<name>/state.json layout) AND legacy final.json."""
|
||||
import google_mail.state as state
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
bundle_output_dir = tmp_path / "services" / "google_mail"
|
||||
bundle_output_dir.mkdir(parents=True)
|
||||
bundle_path = bundle_output_dir / "state.json"
|
||||
final_path = outputdir / "final.json"
|
||||
state.set_snapshot_paths(final_path=final_path, bundle_state_path=bundle_path)
|
||||
|
||||
asyncio.run(mail_send_email(to="bob@test.com", subject="dual-write", body="hi"))
|
||||
|
||||
assert bundle_path.exists(), "<BUNDLE_OUTPUT_DIR>/state.json must be written"
|
||||
assert final_path.exists(), "legacy final.json must still be written"
|
||||
assert bundle_path.read_text() == final_path.read_text()
|
||||
|
||||
|
||||
def test_set_snapshot_paths_partial_update_preserves_existing_path(tmp_path):
|
||||
import google_mail.state as state
|
||||
|
||||
final_path = tmp_path / "final.json"
|
||||
bundle_path = tmp_path / "bundle.json"
|
||||
updated_final_path = tmp_path / "updated-final.json"
|
||||
|
||||
state.set_snapshot_paths(final_path=final_path, bundle_state_path=bundle_path)
|
||||
state.set_snapshot_paths(final_path=updated_final_path)
|
||||
|
||||
assert state.get_final_path() == updated_final_path
|
||||
assert state.get_bundle_state_path() == bundle_path
|
||||
|
||||
|
||||
def test_set_snapshot_paths_can_clear_individual_path(tmp_path):
|
||||
import google_mail.state as state
|
||||
|
||||
final_path = tmp_path / "final.json"
|
||||
bundle_path = tmp_path / "bundle.json"
|
||||
|
||||
state.set_snapshot_paths(final_path=final_path, bundle_state_path=bundle_path)
|
||||
state.set_snapshot_paths(bundle_state_path=None)
|
||||
|
||||
assert state.get_final_path() == final_path
|
||||
assert state.get_bundle_state_path() is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
"""Tests for multi-mailbox support."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from google_mail.models import MultiMailboxData
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_mailbox_services(tmp_path):
|
||||
"""Create two mailbox services with different data."""
|
||||
work_path = tmp_path / "work.json"
|
||||
work_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "alex@acmecorp.com", "name": "Alex Morgan"},
|
||||
"contacts": [{"email": "pam@acmecorp.com", "name": "Pam Chen"}],
|
||||
"folders": [],
|
||||
"emails": [
|
||||
{
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "Work email",
|
||||
"from_addr": "pam@acmecorp.com",
|
||||
"to_addr": "alex@acmecorp.com",
|
||||
"date": "2026-04-14T09:00:00Z",
|
||||
"message_id": "<work1@acmecorp.com>",
|
||||
"body_text": "This is a work email.",
|
||||
"is_read": False,
|
||||
"is_important": False,
|
||||
}
|
||||
],
|
||||
"next_email_id": 10,
|
||||
}
|
||||
)
|
||||
)
|
||||
personal_path = tmp_path / "personal.json"
|
||||
personal_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "alex.m@gmail.com", "name": "Alex"},
|
||||
"contacts": [{"email": "friend@gmail.com", "name": "Best Friend"}],
|
||||
"folders": [],
|
||||
"emails": [
|
||||
{
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "Personal email",
|
||||
"from_addr": "friend@gmail.com",
|
||||
"to_addr": "alex.m@gmail.com",
|
||||
"date": "2026-04-14T10:00:00Z",
|
||||
"message_id": "<personal1@gmail.com>",
|
||||
"body_text": "Hey Alex! Want to grab lunch?",
|
||||
"is_read": False,
|
||||
"is_important": False,
|
||||
}
|
||||
],
|
||||
"next_email_id": 10,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
work_svc = MailboxService(work_path)
|
||||
work_svc.load()
|
||||
personal_svc = MailboxService(personal_path)
|
||||
personal_svc.load()
|
||||
|
||||
return {"work": work_svc, "personal": personal_svc}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
out = tmp_path / "output" / "google_mail"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_server_globals(multi_mailbox_services, outputdir):
|
||||
import google_mail.state as state
|
||||
|
||||
state.set_mailboxes(multi_mailbox_services)
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json")
|
||||
yield
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
class TestListMailboxes:
|
||||
def test_lists_all_mailboxes(self):
|
||||
from google_mail.server import mail_list_mailboxes
|
||||
|
||||
result = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
assert result["total"] == 2
|
||||
ids = [m["mailbox_id"] for m in result["mailboxes"]]
|
||||
assert "work" in ids
|
||||
assert "personal" in ids
|
||||
|
||||
def test_mailbox_info(self):
|
||||
from google_mail.server import mail_list_mailboxes
|
||||
|
||||
result = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
work = next(m for m in result["mailboxes"] if m["mailbox_id"] == "work")
|
||||
assert work["email"] == "alex@acmecorp.com"
|
||||
assert work["email_count"] == 1
|
||||
assert work["contact_count"] == 1
|
||||
|
||||
|
||||
class TestMailboxIsolation:
|
||||
def test_get_emails_from_work(self):
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
result = json.loads(asyncio.run(mail_get_emails(mailbox_id="work")))
|
||||
assert result["total"] == 1
|
||||
assert result["emails"][0]["subject"] == "Work email"
|
||||
|
||||
def test_get_emails_from_personal(self):
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
result = json.loads(asyncio.run(mail_get_emails(mailbox_id="personal")))
|
||||
assert result["total"] == 1
|
||||
assert result["emails"][0]["subject"] == "Personal email"
|
||||
|
||||
def test_contacts_are_isolated(self):
|
||||
from google_mail.server import mail_get_contacts
|
||||
|
||||
work_result = json.loads(asyncio.run(mail_get_contacts(mailbox_id="work")))
|
||||
assert len(work_result["contacts"]) == 1
|
||||
assert work_result["contacts"][0]["name"] == "Pam Chen"
|
||||
|
||||
personal_result = json.loads(asyncio.run(mail_get_contacts(mailbox_id="personal")))
|
||||
assert len(personal_result["contacts"]) == 1
|
||||
assert personal_result["contacts"][0]["name"] == "Best Friend"
|
||||
|
||||
def test_send_email_in_correct_mailbox(self):
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
mail_send_email(
|
||||
to="pam@acmecorp.com",
|
||||
subject="Test from work",
|
||||
body="Sent from work mailbox",
|
||||
mailbox_id="work",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert result["status"] == "sent"
|
||||
assert result["email"]["from"] == "alex@acmecorp.com"
|
||||
|
||||
def test_send_email_wrong_mailbox_contact(self):
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
# pam@acmecorp.com is a work contact, not personal
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
mail_send_email(
|
||||
to="pam@acmecorp.com",
|
||||
subject="Should fail",
|
||||
body="Wrong mailbox",
|
||||
mailbox_id="personal",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert "error" in result # recipient not found in personal contacts
|
||||
|
||||
def test_invalid_mailbox_id(self):
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
asyncio.run(mail_get_emails(mailbox_id="nonexistent"))
|
||||
|
||||
def test_write_to_one_doesnt_affect_other(self):
|
||||
from google_mail.server import mail_create_folder, mail_get_folders
|
||||
|
||||
# Create folder in work
|
||||
asyncio.run(mail_create_folder(folder_name="Projects", mailbox_id="work"))
|
||||
|
||||
# Verify it's in work
|
||||
work_folders = json.loads(asyncio.run(mail_get_folders(mailbox_id="work")))
|
||||
folder_names = [f["name"] for f in work_folders["folders"]]
|
||||
assert "Projects" in folder_names
|
||||
|
||||
# Verify it's NOT in personal
|
||||
personal_folders = json.loads(asyncio.run(mail_get_folders(mailbox_id="personal")))
|
||||
personal_names = [f["name"] for f in personal_folders["folders"]]
|
||||
assert "Projects" not in personal_names
|
||||
|
||||
|
||||
class TestMultiMailboxSnapshot:
|
||||
def test_snapshot_includes_all_mailboxes(self, outputdir):
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
|
||||
asyncio.run(
|
||||
mail_send_email(
|
||||
to="pam@acmecorp.com",
|
||||
subject="Trigger snapshot",
|
||||
body="test",
|
||||
mailbox_id="work",
|
||||
)
|
||||
)
|
||||
|
||||
assert final.exists()
|
||||
snapshot = json.loads(final.read_text())
|
||||
# Multi-mailbox format
|
||||
assert "mailboxes" in snapshot
|
||||
assert "work" in snapshot["mailboxes"]
|
||||
assert "personal" in snapshot["mailboxes"]
|
||||
|
||||
|
||||
class TestMultiMailboxStateTools:
|
||||
def test_export_state_returns_typed_multi_mailbox_state(self):
|
||||
from google_mail.server import export_state
|
||||
|
||||
state = asyncio.run(export_state())
|
||||
|
||||
assert isinstance(state, MultiMailboxData)
|
||||
assert set(state.mailboxes) == {"work", "personal"}
|
||||
|
||||
def test_import_state_accepts_multi_mailbox_dict(self):
|
||||
import google_mail.state as mail_state
|
||||
from google_mail.server import import_state, mail_list_mailboxes
|
||||
|
||||
state = {
|
||||
"mailboxes": {
|
||||
"replacement": {
|
||||
"mailbox": {"email": "replacement@example.com", "name": "Replacement"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = asyncio.run(import_state(state))
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert set(mail_state.get_mailboxes()) == {"replacement"}
|
||||
listed = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
assert listed["mailboxes"][0]["mailbox_id"] == "replacement"
|
||||
|
||||
def test_import_state_flat_payload_replaces_multi_mailbox_registry(self):
|
||||
import google_mail.state as mail_state
|
||||
from google_mail.server import import_state, mail_list_mailboxes
|
||||
|
||||
state = {
|
||||
"mailbox": {"email": "replacement@example.com", "name": "Replacement"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
|
||||
result = asyncio.run(import_state(state))
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert set(mail_state.get_mailboxes()) == {"default"}
|
||||
listed = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
assert listed["total"] == 1
|
||||
assert listed["mailboxes"][0]["mailbox_id"] == "default"
|
||||
assert listed["mailboxes"][0]["email"] == "replacement@example.com"
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Tests for schema validators on MailboxData / Email."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from google_mail.models import Email, MailboxData, MultiMailboxData
|
||||
|
||||
|
||||
def _sample_email(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "s",
|
||||
"from_addr": "bob@example.com",
|
||||
"to_addr": "alice@example.com",
|
||||
"date": "2024-01-15T10:00:00Z",
|
||||
"message_id": "<m@x>",
|
||||
"body_text": "hi",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_email_accepts_list_for_addr_fields():
|
||||
email = Email.model_validate(
|
||||
_sample_email(
|
||||
to_addr=["alice@example.com", "bob@example.com"],
|
||||
cc_addr=["carol@example.com"],
|
||||
bcc_addr=["dan@example.com", "eve@example.com"],
|
||||
)
|
||||
)
|
||||
assert email.to_addr == "alice@example.com, bob@example.com"
|
||||
assert email.cc_addr == "carol@example.com"
|
||||
assert email.bcc_addr == "dan@example.com, eve@example.com"
|
||||
|
||||
|
||||
def test_email_list_validator_trims_and_drops_empties():
|
||||
email = Email.model_validate(_sample_email(to_addr=[" alice@example.com ", "", "bob@example.com"]))
|
||||
assert email.to_addr == "alice@example.com, bob@example.com"
|
||||
|
||||
|
||||
def test_email_addr_string_passthrough_unchanged():
|
||||
email = Email.model_validate(_sample_email(to_addr="alice@example.com, bob@example.com"))
|
||||
assert email.to_addr == "alice@example.com, bob@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alias", ["labelIds", "label_ids"])
|
||||
def test_email_accepts_label_aliases(alias):
|
||||
email = Email.model_validate(_sample_email(**{alias: ["INBOX", "Client"]}))
|
||||
assert email.labels == ["INBOX", "Client"]
|
||||
assert "labelIds" not in email.model_dump(mode="json")
|
||||
assert "label_ids" not in email.model_dump(mode="json")
|
||||
|
||||
|
||||
def test_email_normalizes_labels():
|
||||
email = Email.model_validate(_sample_email(labels=[" client ", "", "client", "INBOX", " "]))
|
||||
assert email.labels == ["client", "INBOX"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["email_id", "folder", "message_id"])
|
||||
def test_email_rejects_empty_identity_fields(field):
|
||||
with pytest.raises(ValidationError):
|
||||
Email.model_validate(_sample_email(**{field: ""}))
|
||||
|
||||
|
||||
def test_email_allows_empty_subject_and_body_for_sparse_messages():
|
||||
email = Email.model_validate(_sample_email(subject="", body_text="", body_html=""))
|
||||
assert email.subject == ""
|
||||
assert email.body_text == ""
|
||||
assert email.body_html == ""
|
||||
|
||||
|
||||
def test_email_accepts_empty_to_addr_for_drafts():
|
||||
email = Email.model_validate(_sample_email(folder="Drafts", to_addr=""))
|
||||
assert email.to_addr == ""
|
||||
|
||||
|
||||
def test_email_accepts_empty_to_addr_for_trash():
|
||||
email = Email.model_validate(_sample_email(folder="Trash", to_addr=""))
|
||||
assert email.to_addr == ""
|
||||
|
||||
|
||||
def test_email_rejects_empty_to_addr_outside_drafts():
|
||||
with pytest.raises(ValidationError):
|
||||
Email.model_validate(_sample_email(to_addr=""))
|
||||
|
||||
|
||||
def test_email_allows_active_message_with_only_cc_recipient():
|
||||
email = Email.model_validate(_sample_email(to_addr="", cc_addr="bob@example.com"))
|
||||
assert email.to_addr == ""
|
||||
assert email.cc_addr == "bob@example.com"
|
||||
|
||||
|
||||
def test_email_normalizes_empty_optional_recipient_fields_to_none():
|
||||
email = Email.model_validate(_sample_email(cc_addr="", bcc_addr=" "))
|
||||
assert email.cc_addr is None
|
||||
assert email.bcc_addr is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("from_addr", "not-an-email"),
|
||||
("to_addr", "not-an-email"),
|
||||
("cc_addr", "alice@example.com, not-an-email"),
|
||||
("bcc_addr", ["dan@example.com", "not-an-email"]),
|
||||
],
|
||||
)
|
||||
def test_email_rejects_invalid_addr_fields(field, value):
|
||||
with pytest.raises(ValidationError):
|
||||
Email.model_validate(_sample_email(**{field: value}))
|
||||
|
||||
|
||||
def test_mailbox_data_import_coerces_email_addr_lists():
|
||||
"""import_state feeds JSON through MailboxData; list-form addrs should round-trip to strings."""
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [
|
||||
_sample_email(to_addr=["alice@example.com", "bob@example.com"]),
|
||||
],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
assert data.emails[0].to_addr == "alice@example.com, bob@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"mailbox": {"email": "not-an-email", "name": "Alice"}},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [{"email": "not-an-email", "name": "Bob"}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"groups": [{"email": "not-an-email", "name": "Team", "members": ["alice@example.com"]}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"groups": [{"email": "team@example.com", "name": "Team", "members": ["not-an-email"]}],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_mailbox_data_rejects_invalid_email_addresses(payload):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"unexpected": True,
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice", "unexpected": True},
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [{"email": "bob@example.com", "name": "Bob", "unexpected": True}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": "Projects", "unexpected": True}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(unexpected=True)],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "notes.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "aGk=",
|
||||
"unexpected": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_mailbox_data_rejects_unknown_state_fields(payload):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(payload)
|
||||
|
||||
|
||||
def test_mailbox_data_still_parses_json_datetime_strings():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(date="2024-01-15T10:00:00Z")],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
assert data.emails[0].date.isoformat() == "2024-01-15T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_non_positive_next_email_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"next_email_id": 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_duplicate_folder_names():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": "Projects"}, {"name": "Projects"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_empty_folder_name():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": ""}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("folder_name", ["INBOX", "Sent", "Drafts", "Trash", "Scheduled"])
|
||||
def test_mailbox_data_rejects_system_folders_as_custom_folders(folder_name):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": folder_name}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_case_insensitive_duplicate_contact_emails():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [
|
||||
{"email": "bob@example.com", "name": "Bob"},
|
||||
{"email": "BOB@example.com", "name": "Other Bob"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_duplicate_email_ids():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(email_id="1", subject="First"),
|
||||
_sample_email(email_id="1", subject="Second"),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_next_email_id_that_collides_with_numeric_email_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(email_id="5")],
|
||||
"next_email_id": 5,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_next_email_id_below_existing_numeric_email_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(email_id="7")],
|
||||
"next_email_id": 6,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_allows_next_email_id_with_non_numeric_email_ids():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(email_id="draft-1")],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
assert data.next_email_id == 1
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_email_with_unknown_folder():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(folder="Projects")],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_accepts_empty_attachment_content():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "empty.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "",
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
assert data.emails[0].attachments[0].size == 0
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_invalid_attachment_base64():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "broken.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "not base64!",
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_empty_attachment_filename():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "aGk=",
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_duplicate_attachment_filenames_per_email():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "notes.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "aGk=",
|
||||
},
|
||||
{
|
||||
"filename": "notes.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "Ynll",
|
||||
},
|
||||
]
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_accepts_scheduled_email_with_scheduled_time():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
folder="Scheduled",
|
||||
scheduled_time="2024-01-16T10:00:00Z",
|
||||
)
|
||||
],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
scheduled_time = data.emails[0].scheduled_time
|
||||
assert scheduled_time is not None
|
||||
assert scheduled_time.isoformat() == "2024-01-16T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_scheduled_folder_without_scheduled_time():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(folder="Scheduled")],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_scheduled_time_outside_scheduled_folder():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(folder="INBOX", scheduled_time="2024-01-16T10:00:00Z")],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_accepts_group_members_from_contacts_and_mailbox_owner():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [
|
||||
{"email": "bob@example.com", "name": "Bob"},
|
||||
],
|
||||
"groups": [
|
||||
{"email": "team@example.com", "name": "Team", "members": ["alice@example.com", "bob@example.com"]},
|
||||
],
|
||||
}
|
||||
)
|
||||
group = data.get_group_by_email("team@example.com")
|
||||
assert group is not None
|
||||
assert group.members == ["alice@example.com", "bob@example.com"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"members",
|
||||
[
|
||||
[],
|
||||
[""],
|
||||
["bob@example.com", "bob@example.com"],
|
||||
["team@example.com"],
|
||||
["unknown@example.com"],
|
||||
],
|
||||
)
|
||||
def test_mailbox_data_rejects_invalid_group_members(members):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [
|
||||
{"email": "bob@example.com", "name": "Bob"},
|
||||
],
|
||||
"groups": [
|
||||
{"email": "team@example.com", "name": "Team", "members": members},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_multi_mailbox_data_validates_nested_mailboxes():
|
||||
state = MultiMailboxData.model_validate(
|
||||
{
|
||||
"mailboxes": {
|
||||
"work": {
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email()],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert state.mailboxes["work"].mailbox.email == "alice@example.com"
|
||||
|
||||
|
||||
def test_multi_mailbox_data_rejects_unknown_wrapper_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
MultiMailboxData.model_validate(
|
||||
{
|
||||
"mailboxes": {
|
||||
"work": {
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
}
|
||||
},
|
||||
"unexpected": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_multi_mailbox_data_rejects_empty_mailbox_map():
|
||||
with pytest.raises(ValidationError):
|
||||
MultiMailboxData.model_validate({"mailboxes": {}})
|
||||
|
||||
|
||||
def test_multi_mailbox_data_rejects_empty_mailbox_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MultiMailboxData.model_validate(
|
||||
{
|
||||
"mailboxes": {
|
||||
"": {
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_addr_fields_advertise_array_variant_in_json_schema():
|
||||
"""MCP clients validate payloads against inputSchema; the list form must be advertised."""
|
||||
email_schema = MailboxData.model_json_schema()["$defs"]["Email"]["properties"]
|
||||
array_variant = {"type": "array", "items": {"type": "string", "format": "email"}}
|
||||
|
||||
to_variants = email_schema["to_addr"]["anyOf"]
|
||||
assert {"type": "string"} in to_variants
|
||||
assert array_variant in to_variants
|
||||
|
||||
cc_variants = email_schema["cc_addr"]["anyOf"]
|
||||
assert {"type": "string"} in cc_variants
|
||||
assert {"type": "null"} in cc_variants
|
||||
assert array_variant in cc_variants
|
||||
|
||||
|
||||
def test_address_book_fields_advertise_email_format_in_json_schema():
|
||||
schema = MailboxData.model_json_schema()
|
||||
|
||||
assert schema["$defs"]["Mailbox"]["properties"]["email"]["format"] == "email"
|
||||
assert schema["$defs"]["Contact"]["properties"]["email"]["format"] == "email"
|
||||
assert schema["$defs"]["ContactGroup"]["properties"]["email"]["format"] == "email"
|
||||
assert schema["$defs"]["ContactGroup"]["properties"]["members"]["items"]["format"] == "email"
|
||||
assert schema["$defs"]["Email"]["properties"]["from_addr"]["format"] == "email"
|
||||
|
||||
|
||||
def test_attachment_content_advertises_base64_encoding_in_json_schema():
|
||||
attachment_schema = MailboxData.model_json_schema()["$defs"]["Attachment"]["properties"]
|
||||
assert attachment_schema["content_base64"]["contentEncoding"] == "base64"
|
||||
|
||||
|
||||
def test_non_empty_state_fields_advertise_min_length_in_json_schema():
|
||||
schema = MailboxData.model_json_schema()["$defs"]
|
||||
assert schema["Folder"]["properties"]["name"]["minLength"] == 1
|
||||
assert schema["Attachment"]["properties"]["filename"]["minLength"] == 1
|
||||
assert schema["Email"]["properties"]["email_id"]["minLength"] == 1
|
||||
assert schema["Email"]["properties"]["folder"]["minLength"] == 1
|
||||
assert schema["Email"]["properties"]["message_id"]["minLength"] == 1
|
||||
|
||||
|
||||
def test_multi_mailbox_data_advertises_non_empty_mailboxes_in_json_schema():
|
||||
mailboxes_schema = MultiMailboxData.model_json_schema()["properties"]["mailboxes"]
|
||||
assert mailboxes_schema["minProperties"] == 1
|
||||
assert mailboxes_schema["propertyNames"]["minLength"] == 1
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Regression tests for the google_mail viewer HTTP app.
|
||||
|
||||
These tests exercise the viewer routes against a real ``MailboxService`` loaded
|
||||
from fixture data. The viewer reads from the shared ``google_mail.state``
|
||||
mailbox registry so it uses the same state surface as the MCP server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import google_mail.state as state_mod
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
from google_mail.viewer import create_mail_viewer_app
|
||||
|
||||
SAMPLE_DATA = {
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [
|
||||
{"email": "bob@example.com", "name": "Bob"},
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"email": "team@example.com",
|
||||
"name": "Team",
|
||||
"members": ["alice@example.com", "bob@example.com"],
|
||||
},
|
||||
],
|
||||
"folders": [],
|
||||
"emails": [
|
||||
{
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "Hello Alice",
|
||||
"from_addr": "bob@example.com",
|
||||
"to_addr": "alice@example.com",
|
||||
"cc_addr": "carol@example.com",
|
||||
"date": "2024-01-15T10:00:00Z",
|
||||
"message_id": "<msg1@example.com>",
|
||||
"body_text": "Hi Alice.",
|
||||
"is_read": False,
|
||||
"is_important": True,
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "doc.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"content_base64": "SGVsbG8=",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"email_id": "2",
|
||||
"folder": "INBOX",
|
||||
"subject": "Re: meeting",
|
||||
"from_addr": "carol@example.com",
|
||||
"to_addr": "alice@example.com",
|
||||
"date": "2024-01-14T09:00:00Z",
|
||||
"message_id": "<msg2@example.com>",
|
||||
"body_text": "See you Monday.",
|
||||
"is_read": True,
|
||||
"is_important": False,
|
||||
},
|
||||
],
|
||||
"next_email_id": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_mailbox(monkeypatch):
|
||||
"""Install a ``default`` MailboxService into the state registry."""
|
||||
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump(SAMPLE_DATA, f)
|
||||
path = Path(f.name)
|
||||
|
||||
service = MailboxService(path)
|
||||
service.load()
|
||||
|
||||
original = dict(state_mod.get_mailboxes())
|
||||
state_mod.set_mailboxes({"default": service})
|
||||
try:
|
||||
yield service
|
||||
finally:
|
||||
state_mod.set_mailboxes(original)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(populated_mailbox) -> TestClient:
|
||||
return TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
|
||||
|
||||
|
||||
class TestViewerRoutes:
|
||||
def test_root_serves_html(self, client: TestClient) -> None:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "<title>Mail</title>" in resp.text
|
||||
|
||||
def test_folders_returns_counts(self, client: TestClient) -> None:
|
||||
resp = client.get("/api/folders")
|
||||
assert resp.status_code == 200
|
||||
folders = {f["name"]: f for f in resp.json()["folders"]}
|
||||
assert "INBOX" in folders
|
||||
assert folders["INBOX"]["total"] == 2
|
||||
assert folders["INBOX"]["unread"] == 1
|
||||
|
||||
def test_emails_list_returns_summary(self, client: TestClient) -> None:
|
||||
resp = client.get("/api/emails?folder=INBOX")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
ids = {e["email_id"] for e in body["emails"]}
|
||||
assert ids == {"1", "2"}
|
||||
first = next(e for e in body["emails"] if e["email_id"] == "1")
|
||||
assert first["has_attachments"] is True
|
||||
assert first["is_important"] is True
|
||||
|
||||
def test_emails_search_filters(self, client: TestClient) -> None:
|
||||
resp = client.get("/api/emails?search=meeting")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["emails"][0]["email_id"] == "2"
|
||||
|
||||
def test_email_detail_returns_full(self, client: TestClient) -> None:
|
||||
resp = client.get("/api/emails/1")
|
||||
assert resp.status_code == 200
|
||||
email = resp.json()["email"]
|
||||
assert email["body_text"] == "Hi Alice."
|
||||
assert email["cc_addr"] == "carol@example.com"
|
||||
assert email["attachments"][0]["filename"] == "doc.pdf"
|
||||
|
||||
def test_email_detail_marks_read(self, client: TestClient, populated_mailbox) -> None:
|
||||
# The unread email becomes read after viewing.
|
||||
client.get("/api/emails/1")
|
||||
email = populated_mailbox.get_email("1")
|
||||
assert email.is_read is True
|
||||
|
||||
def test_email_detail_missing_returns_404(self, client: TestClient) -> None:
|
||||
resp = client.get("/api/emails/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]
|
||||
|
||||
def test_contacts_route(self, client: TestClient) -> None:
|
||||
resp = client.get("/api/contacts")
|
||||
assert resp.status_code == 200
|
||||
contacts = resp.json()["contacts"]
|
||||
assert {"email": "bob@example.com", "name": "Bob"} in contacts
|
||||
groups = resp.json()["groups"]
|
||||
team = next(g for g in groups if g["email"] == "team@example.com")
|
||||
assert team["members"] == ["alice@example.com", "bob@example.com"]
|
||||
|
||||
def test_stats_route(self, client: TestClient) -> None:
|
||||
resp = client.get("/api/stats")
|
||||
assert resp.status_code == 200
|
||||
stats = resp.json()
|
||||
assert stats["total_emails"] == 2
|
||||
assert stats["total_unread"] == 1
|
||||
assert stats["mailbox"]["email"] == "alice@example.com"
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_proxy_token_required_when_set(self, populated_mailbox, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MCP_PROXY_TOKEN", "secret")
|
||||
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
|
||||
|
||||
unauth = client.get("/api/folders")
|
||||
assert unauth.status_code == 403
|
||||
|
||||
ok = client.get("/api/folders", headers={"x-proxy-token": "secret"})
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
class TestMultiMailbox:
|
||||
@pytest.fixture
|
||||
def multi_mailbox(self, monkeypatch):
|
||||
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
|
||||
|
||||
def _load(email: str) -> MailboxService:
|
||||
data = {
|
||||
"mailbox": {"email": email, "name": email},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump(data, f)
|
||||
p = Path(f.name)
|
||||
svc = MailboxService(p)
|
||||
svc.load()
|
||||
return svc
|
||||
|
||||
original = dict(state_mod.get_mailboxes())
|
||||
state_mod.set_mailboxes(
|
||||
{
|
||||
"alice": _load("alice@example.com"),
|
||||
"bob": _load("bob@example.com"),
|
||||
}
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
state_mod.set_mailboxes(original)
|
||||
|
||||
def test_defaults_to_first_mailbox_when_no_default(self, multi_mailbox) -> None:
|
||||
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
|
||||
resp = client.get("/api/stats")
|
||||
assert resp.status_code == 200
|
||||
# Sorted ids → "alice" comes first.
|
||||
assert resp.json()["mailbox"]["email"] == "alice@example.com"
|
||||
|
||||
def test_mailbox_id_query_param_selects(self, multi_mailbox) -> None:
|
||||
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
|
||||
resp = client.get("/api/stats?mailbox_id=bob")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mailbox"]["email"] == "bob@example.com"
|
||||
|
||||
def test_unknown_mailbox_id_returns_404(self, multi_mailbox) -> None:
|
||||
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
|
||||
resp = client.get("/api/folders?mailbox_id=nope")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestLazyInit:
|
||||
"""Regression: when the registry is empty, hitting the viewer must not 500.
|
||||
|
||||
The viewer used to reference a non-existent server mailbox attribute.
|
||||
With the registry empty, ``init_state()`` should be invoked and a default
|
||||
mailbox materialized.
|
||||
"""
|
||||
|
||||
def test_empty_registry_initializes_default(self, monkeypatch) -> None:
|
||||
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
original = dict(state_mod.get_mailboxes())
|
||||
state_mod.set_mailboxes({})
|
||||
try:
|
||||
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
|
||||
for path in ("/api/folders", "/api/emails", "/api/contacts", "/api/stats"):
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 200, f"{path} → {resp.status_code}: {resp.text}"
|
||||
assert "default" in state_mod.get_mailboxes()
|
||||
finally:
|
||||
state_mod.set_mailboxes(original)
|
||||
Reference in New Issue
Block a user