Compare commits
2 Commits
afe35c51bf
...
0459fb1d4c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0459fb1d4c | |||
| 3b687c1a4c |
259
README.md
259
README.md
@@ -1,92 +1,56 @@
|
|||||||
# PIM MCP Server
|
# DAV/IMAP MCP Server
|
||||||
|
|
||||||
A self-hosted MCP server for managing your email, calendar, and contacts through AI assistants.
|
A self-hosted MCP server that connects IMAP/SMTP, CalDAV, and CardDAV to MCP-compatible clients. It exposes email, calendar, and contacts tools and can optionally send new email notifications to a Poke webhook.
|
||||||
|
|
||||||
Connect your existing mail server (IMAP/SMTP) and CalDAV/CardDAV services to any MCP-compatible client.
|
## Features
|
||||||
|
|
||||||
## Prerequisites
|
- Email tools over IMAP/SMTP (list, read, search, send, drafts, flags, unsubscribe)
|
||||||
|
- Calendar tools over CalDAV (list, create, update, delete)
|
||||||
|
- Contacts tools over CardDAV (list, create, update, delete)
|
||||||
|
- Optional email notifications via webhook with IMAP IDLE or polling
|
||||||
|
- SQLite cache with Alembic migrations
|
||||||
|
- API key auth for the MCP endpoint
|
||||||
|
|
||||||
- Docker and Docker Compose (recommended), OR Python 3.12+
|
## Quickstart (Docker Compose)
|
||||||
- An email account with IMAP/SMTP access
|
|
||||||
- (Optional) A CalDAV server for calendars (Nextcloud, Fastmail, Radicale, etc.)
|
|
||||||
- (Optional) A CardDAV server for contacts
|
|
||||||
|
|
||||||
## Setup
|
1. Copy the environment template:
|
||||||
|
|
||||||
### Option 1: Docker Compose (Recommended)
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/your-repo/pim-mcp-server.git
|
|
||||||
cd pim-mcp-server
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create your environment file**
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Edit `.env` with your credentials**
|
2. Edit `.env` with your credentials.
|
||||||
```bash
|
|
||||||
# Required: Generate an API key
|
|
||||||
MCP_API_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
|
|
||||||
|
|
||||||
# Then edit .env with your mail server details
|
3. Start the server:
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Start the server**
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Verify it's running**
|
4. Verify it is running:
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/mcp
|
curl http://localhost:8000/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Local Python
|
## Local Run (Python)
|
||||||
|
|
||||||
1. **Clone and setup environment**
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-repo/pim-mcp-server.git
|
|
||||||
cd pim-mcp-server
|
|
||||||
|
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configure**
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env # Add your credentials
|
# Edit .env
|
||||||
```
|
|
||||||
|
|
||||||
3. **Initialize database**
|
|
||||||
```bash
|
|
||||||
# Create initial migration (first time only)
|
|
||||||
alembic revision --autogenerate -m "Initial tables"
|
|
||||||
alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Run**
|
|
||||||
```bash
|
|
||||||
python src/server.py
|
python src/server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Edit `.env` with your service credentials. Only configure the services you want to use.
|
All settings are read from `.env`. Configure only the services you want to enable.
|
||||||
|
|
||||||
### Minimal Setup (Email Only)
|
### Minimal email-only setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server
|
MCP_API_KEY=your-secret-key
|
||||||
MCP_API_KEY=your-secret-key-here
|
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|
||||||
# Email
|
|
||||||
IMAP_HOST=imap.example.com
|
IMAP_HOST=imap.example.com
|
||||||
IMAP_USERNAME=you@example.com
|
IMAP_USERNAME=you@example.com
|
||||||
IMAP_PASSWORD=your-password
|
IMAP_PASSWORD=your-password
|
||||||
@@ -95,121 +59,67 @@ SMTP_USERNAME=you@example.com
|
|||||||
SMTP_PASSWORD=your-password
|
SMTP_PASSWORD=your-password
|
||||||
SMTP_FROM_EMAIL=you@example.com
|
SMTP_FROM_EMAIL=you@example.com
|
||||||
|
|
||||||
# Disable other services
|
|
||||||
ENABLE_CALENDAR=false
|
ENABLE_CALENDAR=false
|
||||||
ENABLE_CONTACTS=false
|
ENABLE_CONTACTS=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Full Setup (Email + Calendar + Contacts)
|
### Full setup (email + calendar + contacts)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server
|
MCP_API_KEY=your-secret-key
|
||||||
MCP_API_KEY=your-secret-key-here
|
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|
||||||
# Email (IMAP/SMTP)
|
|
||||||
IMAP_HOST=imap.example.com
|
IMAP_HOST=imap.example.com
|
||||||
IMAP_PORT=993
|
IMAP_PORT=993
|
||||||
IMAP_USERNAME=you@example.com
|
IMAP_USERNAME=you@example.com
|
||||||
IMAP_PASSWORD=your-password
|
IMAP_PASSWORD=your-password
|
||||||
|
IMAP_USE_SSL=true
|
||||||
|
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USERNAME=you@example.com
|
SMTP_USERNAME=you@example.com
|
||||||
SMTP_PASSWORD=your-password
|
SMTP_PASSWORD=your-password
|
||||||
|
SMTP_USE_TLS=true
|
||||||
SMTP_FROM_EMAIL=you@example.com
|
SMTP_FROM_EMAIL=you@example.com
|
||||||
SMTP_FROM_NAME=Your Name
|
SMTP_FROM_NAME=Your Name
|
||||||
|
|
||||||
# Calendar (CalDAV)
|
|
||||||
CALDAV_URL=https://caldav.example.com/dav
|
CALDAV_URL=https://caldav.example.com/dav
|
||||||
CALDAV_USERNAME=you@example.com
|
CALDAV_USERNAME=you@example.com
|
||||||
CALDAV_PASSWORD=your-password
|
CALDAV_PASSWORD=your-password
|
||||||
|
|
||||||
# Contacts (CardDAV)
|
|
||||||
CARDDAV_URL=https://carddav.example.com/dav
|
CARDDAV_URL=https://carddav.example.com/dav
|
||||||
CARDDAV_USERNAME=you@example.com
|
CARDDAV_USERNAME=you@example.com
|
||||||
CARDDAV_PASSWORD=your-password
|
CARDDAV_PASSWORD=your-password
|
||||||
```
|
```
|
||||||
|
|
||||||
### Provider-Specific Examples
|
### Email notifications (Poke webhook)
|
||||||
|
|
||||||
<details>
|
Enable notifications to send new-email alerts to Poke. The server will use IMAP IDLE when available and fall back to polling.
|
||||||
<summary><strong>Fastmail</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
IMAP_HOST=imap.fastmail.com
|
ENABLE_EMAIL_NOTIFICATIONS=true
|
||||||
IMAP_PORT=993
|
NOTIFICATION_MAILBOXES=INBOX,Updates
|
||||||
SMTP_HOST=smtp.fastmail.com
|
NOTIFICATION_POLL_INTERVAL=60
|
||||||
SMTP_PORT=587
|
NOTIFICATION_IDLE_TIMEOUT=1680
|
||||||
CALDAV_URL=https://caldav.fastmail.com/dav/calendars/user/you@fastmail.com
|
|
||||||
CARDDAV_URL=https://carddav.fastmail.com/dav/addressbooks/user/you@fastmail.com
|
POKE_WEBHOOK_URL=https://poke.com/api/v1/inbound-sms/webhook
|
||||||
|
POKE_API_KEY=your-poke-api-key
|
||||||
|
POKE_WEBHOOK_TIMEOUT=30
|
||||||
|
POKE_WEBHOOK_MAX_RETRIES=3
|
||||||
```
|
```
|
||||||
Use an [app-specific password](https://www.fastmail.help/hc/en-us/articles/360058752854).
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
## MCP Client Setup
|
||||||
<summary><strong>Nextcloud</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
### MCP Inspector
|
||||||
# Use your mail server for IMAP/SMTP
|
|
||||||
IMAP_HOST=mail.example.com
|
|
||||||
SMTP_HOST=mail.example.com
|
|
||||||
|
|
||||||
# Nextcloud for CalDAV/CardDAV
|
|
||||||
CALDAV_URL=https://cloud.example.com/remote.php/dav
|
|
||||||
CARDDAV_URL=https://cloud.example.com/remote.php/dav
|
|
||||||
CALDAV_USERNAME=your-nextcloud-user
|
|
||||||
CARDDAV_USERNAME=your-nextcloud-user
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Gmail</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
IMAP_HOST=imap.gmail.com
|
|
||||||
IMAP_PORT=993
|
|
||||||
SMTP_HOST=smtp.gmail.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
```
|
|
||||||
You must use an [App Password](https://support.google.com/accounts/answer/185833) (not your regular password).
|
|
||||||
|
|
||||||
Note: Gmail's CalDAV/CardDAV requires OAuth2, which is not currently supported.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Mailcow</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
IMAP_HOST=mail.example.com
|
|
||||||
SMTP_HOST=mail.example.com
|
|
||||||
CALDAV_URL=https://mail.example.com/SOGo/dav
|
|
||||||
CARDDAV_URL=https://mail.example.com/SOGo/dav
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Radicale (Self-hosted)</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CALDAV_URL=https://radicale.example.com/user/
|
|
||||||
CARDDAV_URL=https://radicale.example.com/user/
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Connecting to MCP Clients
|
|
||||||
|
|
||||||
### MCP Inspector (Testing)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @modelcontextprotocol/inspector
|
npx @modelcontextprotocol/inspector
|
||||||
```
|
```
|
||||||
- Transport: **Streamable HTTP**
|
- Transport: Streamable HTTP
|
||||||
- URL: `http://localhost:8000/mcp`
|
- URL: `http://localhost:8000/mcp`
|
||||||
|
|
||||||
### Claude Desktop
|
### Claude Desktop
|
||||||
|
|
||||||
Add to your Claude Desktop config (`~/.config/claude/claude_desktop_config.json` on Linux/Mac):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
@@ -223,99 +133,32 @@ Add to your Claude Desktop config (`~/.config/claude/claude_desktop_config.json`
|
|||||||
|
|
||||||
### Poke
|
### Poke
|
||||||
|
|
||||||
Go to [poke.com/settings/connections](https://poke.com/settings/connections) and add your server URL.
|
Add your MCP endpoint at https://poke.com/settings/connections.
|
||||||
|
|
||||||
## Available Tools
|
## Available Tools
|
||||||
|
|
||||||
Once connected, your AI assistant can use these tools:
|
|
||||||
|
|
||||||
| Category | Tools |
|
| Category | Tools |
|
||||||
|----------|-------|
|
| --- | --- |
|
||||||
| **Email** | `list_mailboxes`, `list_emails`, `read_email`, `search_emails`, `move_email`, `delete_email`, `send_email` |
|
| Email | `list_mailboxes`, `list_emails`, `list_drafts`, `read_email`, `search_emails`, `move_email`, `delete_email`, `delete_draft`, `save_draft`, `edit_draft`, `send_email`, `set_email_flags`, `unsubscribe_email` |
|
||||||
| **Calendar** | `list_calendars`, `list_events`, `get_event`, `create_event`, `update_event`, `delete_event` |
|
| Calendar | `list_calendars`, `list_events`, `get_event`, `create_event`, `update_event`, `delete_event` |
|
||||||
| **Contacts** | `list_addressbooks`, `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `delete_contact` |
|
| Contacts | `list_addressbooks`, `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `delete_contact` |
|
||||||
| **System** | `get_server_info` |
|
| System | `get_server_info` |
|
||||||
|
|
||||||
## Database Migrations
|
## Database and Migrations
|
||||||
|
|
||||||
The server uses SQLModel with Alembic for database migrations.
|
The server uses SQLite (default: `/data/cache.db`) and Alembic.
|
||||||
|
|
||||||
### When you update the code
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull latest changes
|
alembic revision --autogenerate -m "Describe change"
|
||||||
git pull
|
|
||||||
|
|
||||||
# Run any new migrations
|
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
# Restart
|
|
||||||
docker compose restart
|
|
||||||
# or: python src/server.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding custom fields
|
|
||||||
|
|
||||||
1. Edit `src/database/models.py`
|
|
||||||
2. Generate migration: `alembic revision --autogenerate -m "Description"`
|
|
||||||
3. Apply: `alembic upgrade head`
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Connection refused
|
- Connection refused: check `docker compose ps` or `curl http://localhost:8000/mcp`
|
||||||
- Check the server is running: `docker compose ps` or `curl localhost:8000/mcp`
|
- Auth errors: confirm `MCP_API_KEY` and client config
|
||||||
- Check logs: `docker compose logs -f`
|
- IMAP/SMTP failures: verify credentials and app-specific passwords
|
||||||
|
- CalDAV/CardDAV failures: confirm base URL and username
|
||||||
### Authentication failed (IMAP/SMTP)
|
|
||||||
- Verify credentials in `.env`
|
|
||||||
- Many providers require app-specific passwords (Gmail, Fastmail, etc.)
|
|
||||||
- Check if 2FA is enabled on your account
|
|
||||||
|
|
||||||
### CalDAV/CardDAV not working
|
|
||||||
- Verify the URL is correct (try opening it in a browser)
|
|
||||||
- Check if your provider requires a specific URL format
|
|
||||||
- Some providers need the full path including username
|
|
||||||
|
|
||||||
### Service disabled
|
|
||||||
If you see "service: disabled (not configured)", check that:
|
|
||||||
- All required env vars are set (host, username, password)
|
|
||||||
- `ENABLE_*` is not set to `false`
|
|
||||||
|
|
||||||
### View logs
|
|
||||||
```bash
|
|
||||||
# Docker
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Local
|
|
||||||
# Logs print to stdout
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
├── src/
|
|
||||||
│ ├── server.py # Entry point
|
|
||||||
│ ├── config.py # Environment configuration
|
|
||||||
│ ├── database/ # SQLModel ORM
|
|
||||||
│ │ ├── models.py # Table definitions
|
|
||||||
│ │ └── connection.py # Database connection
|
|
||||||
│ ├── models/ # Pydantic models (API)
|
|
||||||
│ ├── services/ # Business logic
|
|
||||||
│ └── tools/ # MCP tool definitions
|
|
||||||
├── migrations/ # Alembic migrations
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .env.example
|
|
||||||
└── requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- Never commit `.env` files
|
|
||||||
- Use app-specific passwords where available
|
|
||||||
- The Docker container runs as non-root
|
|
||||||
- Consider running behind a reverse proxy with HTTPS for remote access
|
|
||||||
- The `MCP_API_KEY` is optional but recommended for production
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import email
|
|||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.utils import formataddr, parseaddr
|
from email.utils import formataddr, parseaddr, formatdate, make_msgid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import re
|
import re
|
||||||
@@ -200,6 +200,25 @@ class EmailService:
|
|||||||
emails=emails, total=total, mailbox=mailbox, limit=limit, offset=offset
|
emails=emails, total=total, mailbox=mailbox, limit=limit, offset=offset
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_drafts(
|
||||||
|
self,
|
||||||
|
mailbox: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
include_body: bool = False,
|
||||||
|
) -> EmailList:
|
||||||
|
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
|
||||||
|
try:
|
||||||
|
return self.list_emails(draft_mailbox, limit, offset, include_body)
|
||||||
|
except Exception:
|
||||||
|
return EmailList(
|
||||||
|
emails=[],
|
||||||
|
total=0,
|
||||||
|
mailbox=draft_mailbox,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
def read_email(
|
def read_email(
|
||||||
self, mailbox: str, email_id: str, format: str = "text"
|
self, mailbox: str, email_id: str, format: str = "text"
|
||||||
) -> Optional[Email]:
|
) -> Optional[Email]:
|
||||||
@@ -241,7 +260,7 @@ class EmailService:
|
|||||||
|
|
||||||
# Get headers
|
# Get headers
|
||||||
headers = {}
|
headers = {}
|
||||||
for key in ["Message-ID", "In-Reply-To", "References", "X-Priority", "List-Unsubscribe", "List-Unsubscribe-Post"]:
|
for key in ["Message-ID", "In-Reply-To", "References", "Reply-To", "X-Priority", "List-Unsubscribe", "List-Unsubscribe-Post"]:
|
||||||
value = msg.get(key)
|
value = msg.get(key)
|
||||||
if value:
|
if value:
|
||||||
headers[key] = decode_mime_header(value)
|
headers[key] = decode_mime_header(value)
|
||||||
@@ -406,6 +425,115 @@ class EmailService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return OperationResult(success=False, message=str(e))
|
return OperationResult(success=False, message=str(e))
|
||||||
|
|
||||||
|
def delete_draft(
|
||||||
|
self, email_id: str, mailbox: Optional[str] = None, permanent: bool = False
|
||||||
|
) -> OperationResult:
|
||||||
|
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
|
||||||
|
return self.delete_email(email_id, draft_mailbox, permanent)
|
||||||
|
|
||||||
|
def save_draft(
|
||||||
|
self,
|
||||||
|
to: list[str],
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
cc: Optional[list[str]] = None,
|
||||||
|
bcc: Optional[list[str]] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
mailbox: Optional[str] = None,
|
||||||
|
) -> OperationResult:
|
||||||
|
try:
|
||||||
|
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
|
||||||
|
msg = self._build_draft_message(
|
||||||
|
to=to,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
cc=cc,
|
||||||
|
bcc=bcc,
|
||||||
|
reply_to=reply_to,
|
||||||
|
html_body=html_body,
|
||||||
|
)
|
||||||
|
client = self._get_imap_client()
|
||||||
|
append_result = client.append(
|
||||||
|
draft_mailbox,
|
||||||
|
msg.as_bytes(),
|
||||||
|
flags=["\\Draft"],
|
||||||
|
)
|
||||||
|
draft_id = None
|
||||||
|
if append_result and isinstance(append_result, tuple) and len(append_result) > 1:
|
||||||
|
draft_id = str(append_result[1])
|
||||||
|
return OperationResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Draft saved to {draft_mailbox}",
|
||||||
|
id=draft_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return OperationResult(success=False, message=str(e))
|
||||||
|
|
||||||
|
def update_draft(
|
||||||
|
self,
|
||||||
|
email_id: str,
|
||||||
|
mailbox: Optional[str] = None,
|
||||||
|
to: Optional[list[str]] = None,
|
||||||
|
subject: Optional[str] = None,
|
||||||
|
body: Optional[str] = None,
|
||||||
|
cc: Optional[list[str]] = None,
|
||||||
|
bcc: Optional[list[str]] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
) -> OperationResult:
|
||||||
|
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
|
||||||
|
try:
|
||||||
|
existing = self.read_email(draft_mailbox, email_id, format="both")
|
||||||
|
except Exception as e:
|
||||||
|
return OperationResult(success=False, message=str(e), id=email_id)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Draft {email_id} not found in {draft_mailbox}",
|
||||||
|
id=email_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_to = to if to is not None else [addr.email for addr in existing.to_addresses]
|
||||||
|
resolved_cc = cc if cc is not None else [addr.email for addr in existing.cc_addresses]
|
||||||
|
resolved_bcc = bcc if bcc is not None else [addr.email for addr in existing.bcc_addresses]
|
||||||
|
resolved_subject = subject if subject is not None else existing.subject
|
||||||
|
resolved_body = body if body is not None else (existing.body_text or "")
|
||||||
|
resolved_html = html_body if html_body is not None else existing.body_html
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = self._get_imap_client()
|
||||||
|
client.select_folder(draft_mailbox)
|
||||||
|
uid = int(email_id)
|
||||||
|
client.delete_messages([uid])
|
||||||
|
client.expunge()
|
||||||
|
|
||||||
|
msg = self._build_draft_message(
|
||||||
|
to=resolved_to,
|
||||||
|
subject=resolved_subject,
|
||||||
|
body=resolved_body,
|
||||||
|
cc=resolved_cc,
|
||||||
|
bcc=resolved_bcc,
|
||||||
|
reply_to=reply_to,
|
||||||
|
html_body=resolved_html,
|
||||||
|
)
|
||||||
|
append_result = client.append(
|
||||||
|
draft_mailbox,
|
||||||
|
msg.as_bytes(),
|
||||||
|
flags=["\\Draft"],
|
||||||
|
)
|
||||||
|
draft_id = None
|
||||||
|
if append_result and isinstance(append_result, tuple) and len(append_result) > 1:
|
||||||
|
draft_id = str(append_result[1])
|
||||||
|
return OperationResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Draft {email_id} updated in {draft_mailbox}",
|
||||||
|
id=draft_id or email_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return OperationResult(success=False, message=str(e), id=email_id)
|
||||||
|
|
||||||
async def send_email(
|
async def send_email(
|
||||||
self,
|
self,
|
||||||
to: list[str],
|
to: list[str],
|
||||||
@@ -575,6 +703,47 @@ class EmailService:
|
|||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _find_drafts_folder(self) -> Optional[str]:
|
||||||
|
client = self._get_imap_client()
|
||||||
|
folders = client.list_folders()
|
||||||
|
|
||||||
|
draft_names = ["Drafts", "Draft", "INBOX.Drafts", "[Gmail]/Drafts"]
|
||||||
|
for flags, delimiter, name in folders:
|
||||||
|
if name in draft_names or b"\\Drafts" in flags:
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_draft_message(
|
||||||
|
self,
|
||||||
|
to: list[str],
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
cc: Optional[list[str]] = None,
|
||||||
|
bcc: Optional[list[str]] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
) -> MIMEMultipart:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = formataddr(
|
||||||
|
(self.settings.smtp_from_name or "", self.settings.smtp_from_email)
|
||||||
|
)
|
||||||
|
msg["To"] = ", ".join(to)
|
||||||
|
msg["Date"] = formatdate(localtime=True)
|
||||||
|
msg["Message-ID"] = make_msgid()
|
||||||
|
|
||||||
|
if cc:
|
||||||
|
msg["Cc"] = ", ".join(cc)
|
||||||
|
if bcc:
|
||||||
|
msg["Bcc"] = ", ".join(bcc)
|
||||||
|
if reply_to:
|
||||||
|
msg["Reply-To"] = reply_to
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body or "", "plain", "utf-8"))
|
||||||
|
if html_body:
|
||||||
|
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||||
|
return msg
|
||||||
|
|
||||||
def set_flags(
|
def set_flags(
|
||||||
self,
|
self,
|
||||||
email_id: str,
|
email_id: str,
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
|||||||
result = service.list_emails(mailbox, limit, offset, include_body)
|
result = service.list_emails(mailbox, limit, offset, include_body)
|
||||||
return result.model_dump()
|
return result.model_dump()
|
||||||
|
|
||||||
|
@mcp.tool(description="List draft emails in the Drafts mailbox with pagination.")
|
||||||
|
def list_drafts(
|
||||||
|
mailbox: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
include_body: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
List draft emails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mailbox: Drafts mailbox/folder override (default: auto-detect)
|
||||||
|
limit: Maximum number of drafts to return (default: 50)
|
||||||
|
offset: Number of drafts to skip for pagination (default: 0)
|
||||||
|
include_body: Whether to include body snippets (default: False)
|
||||||
|
"""
|
||||||
|
result = service.list_drafts(mailbox, limit, offset, include_body)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
@mcp.tool(description="Read a specific email by ID with full body content and attachment information.")
|
@mcp.tool(description="Read a specific email by ID with full body content and attachment information.")
|
||||||
def read_email(
|
def read_email(
|
||||||
mailbox: str,
|
mailbox: str,
|
||||||
@@ -108,6 +127,89 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
|||||||
result = service.delete_email(email_id, mailbox, permanent)
|
result = service.delete_email(email_id, mailbox, permanent)
|
||||||
return result.model_dump()
|
return result.model_dump()
|
||||||
|
|
||||||
|
@mcp.tool(description="Delete a drafted email by ID, optionally permanently.")
|
||||||
|
def delete_draft(
|
||||||
|
email_id: str,
|
||||||
|
mailbox: Optional[str] = None,
|
||||||
|
permanent: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Delete a draft email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: The unique ID of the draft
|
||||||
|
mailbox: Drafts mailbox/folder override (default: auto-detect)
|
||||||
|
permanent: If True, permanently delete; if False, move to Trash (default: False)
|
||||||
|
"""
|
||||||
|
result = service.delete_draft(email_id, mailbox, permanent)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
@mcp.tool(description="Save a new draft email to the Drafts mailbox.")
|
||||||
|
def save_draft(
|
||||||
|
to: list[str],
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
cc: Optional[list[str]] = None,
|
||||||
|
bcc: Optional[list[str]] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
mailbox: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Save a new email draft.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to: List of recipient email addresses
|
||||||
|
subject: Email subject line
|
||||||
|
body: Plain text email body
|
||||||
|
cc: List of CC recipients (optional)
|
||||||
|
bcc: List of BCC recipients (optional)
|
||||||
|
reply_to: Reply-to address (optional)
|
||||||
|
html_body: HTML version of the email body (optional)
|
||||||
|
mailbox: Drafts mailbox/folder override (default: auto-detect)
|
||||||
|
"""
|
||||||
|
result = service.save_draft(to, subject, body, cc, bcc, reply_to, html_body, mailbox)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
@mcp.tool(description="Edit an existing draft email. Only provided fields will be modified.")
|
||||||
|
def edit_draft(
|
||||||
|
email_id: str,
|
||||||
|
mailbox: Optional[str] = None,
|
||||||
|
to: Optional[list[str]] = None,
|
||||||
|
subject: Optional[str] = None,
|
||||||
|
body: Optional[str] = None,
|
||||||
|
cc: Optional[list[str]] = None,
|
||||||
|
bcc: Optional[list[str]] = None,
|
||||||
|
reply_to: Optional[str] = None,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Update an existing draft email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: The unique ID of the draft
|
||||||
|
mailbox: Drafts mailbox/folder override (default: auto-detect)
|
||||||
|
to: List of recipient email addresses
|
||||||
|
subject: Email subject line
|
||||||
|
body: Plain text email body
|
||||||
|
cc: List of CC recipients (optional)
|
||||||
|
bcc: List of BCC recipients (optional)
|
||||||
|
reply_to: Reply-to address (optional)
|
||||||
|
html_body: HTML version of the email body (optional)
|
||||||
|
"""
|
||||||
|
result = service.update_draft(
|
||||||
|
email_id,
|
||||||
|
mailbox,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
reply_to,
|
||||||
|
html_body,
|
||||||
|
)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
@mcp.tool(description="Send a new email via SMTP. Supports plain text and HTML content, CC, BCC, and reply-to.")
|
@mcp.tool(description="Send a new email via SMTP. Supports plain text and HTML content, CC, BCC, and reply-to.")
|
||||||
async def send_email(
|
async def send_email(
|
||||||
to: list[str],
|
to: list[str],
|
||||||
|
|||||||
Reference in New Issue
Block a user