Revise contacts and email tools
All checks were successful
Build And Test / publish (push) Successful in 48s
All checks were successful
Build And Test / publish (push) Successful in 48s
This commit is contained in:
@@ -54,6 +54,7 @@ CALDAV_PASSWORD=your-caldav-password
|
||||
CARDDAV_URL=https://carddav.example.com/dav
|
||||
CARDDAV_USERNAME=user@example.com
|
||||
CARDDAV_PASSWORD=your-carddav-password
|
||||
CONTACTS_ADDRESSBOOK_ID=/dav/addressbooks/users/user@example.com/contacts/
|
||||
|
||||
# =============================================================================
|
||||
# Cache Configuration
|
||||
|
||||
34
README.md
34
README.md
@@ -4,7 +4,7 @@ A self-hosted MCP server that connects IMAP/SMTP, CalDAV, and CardDAV to MCP-com
|
||||
|
||||
## Features
|
||||
|
||||
- Email tools over IMAP/SMTP (list, read, search, send, drafts, flags, unsubscribe)
|
||||
- Email tools over IMAP/SMTP (list, read, search, drafts, 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
|
||||
@@ -94,8 +94,11 @@ ICS_CALENDAR_TIMEOUT=20
|
||||
CARDDAV_URL=https://carddav.example.com/dav
|
||||
CARDDAV_USERNAME=you@example.com
|
||||
CARDDAV_PASSWORD=your-password
|
||||
CONTACTS_ADDRESSBOOK_ID=/dav/addressbooks/users/you@example.com/contacts/
|
||||
```
|
||||
|
||||
Contacts tools always use `CONTACTS_ADDRESSBOOK_ID`. Listing address books is not exposed via MCP.
|
||||
|
||||
ICS calendars are optional and read-only. Set `ICS_CALENDARS` to a comma-separated list of entries, each as `name|url` or just `url` if you want the name inferred.
|
||||
|
||||
### Email notifications (Poke webhook)
|
||||
@@ -145,32 +148,47 @@ Add your MCP endpoint at https://poke.com/settings/connections.
|
||||
|
||||
| Category | Tools |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| Email | `list_mailboxes`, `list_emails`, `list_drafts`, `read_email`, `search_emails`, `move_email`, `delete_email`, `delete_draft`, `save_draft`, `edit_draft`, `send_draft`, `set_email_flags`, `unsubscribe_maillist` |
|
||||
| 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_contacts`, `get_contact`, `create_contact`, `update_contact`, `delete_contact` |
|
||||
| System | `get_server_info` |
|
||||
|
||||
### Sending email
|
||||
|
||||
Emails are sent only from drafts. Create or edit a draft with `save_draft`/`edit_draft`, then send it with `send_draft` using the returned draft ID.
|
||||
|
||||
### Replying to an email
|
||||
|
||||
Use `reply_to_email_id` on `save_draft`, `edit_draft`, or `send_email` to create a reply without a separate tool.
|
||||
Use `in_reply_to_email_id` on `save_draft` or `edit_draft` to create a reply without a separate tool. Then send it with `send_draft`.
|
||||
|
||||
- Provide `reply_to_email_id` (and optionally `reply_mailbox`, default `INBOX`).
|
||||
- Provide `in_reply_to_email_id` (and optionally `in_reply_to_mailbox`, default `INBOX`).
|
||||
- `reply_all=true` includes original recipients; otherwise it replies to the sender/Reply-To.
|
||||
- If `to`/`subject` are omitted, they are derived from the original email; `body` is still required.
|
||||
- `in_reply_to_email_id` is the email UID from `list_emails`/`read_email`, not the RFC Message-ID header.
|
||||
|
||||
Example (send a reply):
|
||||
```json
|
||||
{
|
||||
"tool": "send_email",
|
||||
"tool": "save_draft",
|
||||
"args": {
|
||||
"reply_to_email_id": "12345",
|
||||
"reply_mailbox": "INBOX",
|
||||
"in_reply_to_email_id": "12345",
|
||||
"in_reply_to_mailbox": "INBOX",
|
||||
"reply_all": true,
|
||||
"body": "Thanks — sounds good to me."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then send the draft by its returned ID:
|
||||
```json
|
||||
{
|
||||
"tool": "send_draft",
|
||||
"args": {
|
||||
"email_id": "67890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database and Migrations
|
||||
|
||||
The server uses SQLite (default: `/data/cache.db`) and Alembic.
|
||||
|
||||
@@ -40,6 +40,10 @@ class Settings(BaseSettings):
|
||||
carddav_url: Optional[str] = Field(default=None, alias="CARDDAV_URL")
|
||||
carddav_username: Optional[str] = Field(default=None, alias="CARDDAV_USERNAME")
|
||||
carddav_password: Optional[SecretStr] = Field(default=None, alias="CARDDAV_PASSWORD")
|
||||
contacts_addressbook_id: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="CONTACTS_ADDRESSBOOK_ID",
|
||||
)
|
||||
|
||||
# SQLite Cache
|
||||
sqlite_path: str = Field(default="/data/cache.db", alias="SQLITE_PATH")
|
||||
@@ -150,6 +154,7 @@ class Settings(BaseSettings):
|
||||
self.carddav_url,
|
||||
self.carddav_username,
|
||||
self.carddav_password,
|
||||
self.contacts_addressbook_id,
|
||||
])
|
||||
|
||||
def is_notification_configured(self) -> bool:
|
||||
|
||||
@@ -114,12 +114,13 @@ class ContactsService:
|
||||
|
||||
def list_contacts(
|
||||
self,
|
||||
addressbook_id: str,
|
||||
addressbook_id: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> ContactList:
|
||||
client = self._get_client()
|
||||
addressbook_id = self._resolve_addressbook_id(addressbook_id)
|
||||
|
||||
# Build URL
|
||||
addressbook_url = self._build_url(addressbook_id)
|
||||
@@ -188,8 +189,9 @@ class ContactsService:
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
def get_contact(self, addressbook_id: str, contact_id: str) -> Optional[Contact]:
|
||||
def get_contact(self, contact_id: str, addressbook_id: Optional[str] = None) -> Optional[Contact]:
|
||||
client = self._get_client()
|
||||
addressbook_id = self._resolve_addressbook_id(addressbook_id)
|
||||
|
||||
# Build URL
|
||||
contact_url = self._build_url(contact_id)
|
||||
@@ -206,7 +208,7 @@ class ContactsService:
|
||||
|
||||
def create_contact(
|
||||
self,
|
||||
addressbook_id: str,
|
||||
addressbook_id: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
@@ -219,6 +221,7 @@ class ContactsService:
|
||||
birthday: Optional[str] = None,
|
||||
) -> Contact:
|
||||
client = self._get_client()
|
||||
addressbook_id = self._resolve_addressbook_id(addressbook_id)
|
||||
|
||||
# Create vCard
|
||||
vcard = vobject.vCard()
|
||||
@@ -311,8 +314,8 @@ class ContactsService:
|
||||
|
||||
def update_contact(
|
||||
self,
|
||||
addressbook_id: str,
|
||||
contact_id: str,
|
||||
addressbook_id: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
@@ -324,7 +327,7 @@ class ContactsService:
|
||||
notes: Optional[str] = None,
|
||||
) -> Optional[Contact]:
|
||||
# Get existing contact
|
||||
existing = self.get_contact(addressbook_id, contact_id)
|
||||
existing = self.get_contact(contact_id, addressbook_id)
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
@@ -342,12 +345,13 @@ class ContactsService:
|
||||
}
|
||||
|
||||
# Delete and recreate (simpler than partial update)
|
||||
self.delete_contact(addressbook_id, contact_id)
|
||||
self.delete_contact(contact_id, addressbook_id)
|
||||
return self.create_contact(addressbook_id, **updated_data)
|
||||
|
||||
def delete_contact(self, addressbook_id: str, contact_id: str) -> OperationResult:
|
||||
def delete_contact(self, contact_id: str, addressbook_id: Optional[str] = None) -> OperationResult:
|
||||
try:
|
||||
client = self._get_client()
|
||||
addressbook_id = self._resolve_addressbook_id(addressbook_id)
|
||||
|
||||
# Build URL
|
||||
contact_url = self._build_url(contact_id)
|
||||
@@ -481,3 +485,10 @@ class ContactsService:
|
||||
notes=notes,
|
||||
birthday=birthday,
|
||||
)
|
||||
|
||||
def _resolve_addressbook_id(self, addressbook_id: Optional[str]) -> str:
|
||||
if addressbook_id:
|
||||
return addressbook_id
|
||||
if self.settings.contacts_addressbook_id:
|
||||
return self.settings.contacts_addressbook_id
|
||||
raise ValueError("CONTACTS_ADDRESSBOOK_ID must be set to use contacts tools")
|
||||
|
||||
@@ -438,19 +438,18 @@ class EmailService:
|
||||
body: Optional[str] = None,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
mailbox: Optional[str] = None,
|
||||
reply_to_email_id: Optional[str] = None,
|
||||
reply_mailbox: Optional[str] = None,
|
||||
in_reply_to_email_id: Optional[str] = None,
|
||||
in_reply_to_mailbox: Optional[str] = None,
|
||||
reply_all: bool = False,
|
||||
) -> OperationResult:
|
||||
try:
|
||||
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
|
||||
if reply_to_email_id:
|
||||
if in_reply_to_email_id:
|
||||
context, error = self._get_reply_context(
|
||||
reply_mailbox or "INBOX",
|
||||
reply_to_email_id,
|
||||
in_reply_to_mailbox or "INBOX",
|
||||
in_reply_to_email_id,
|
||||
reply_all,
|
||||
cc,
|
||||
self.settings.smtp_from_email,
|
||||
@@ -482,7 +481,6 @@ class EmailService:
|
||||
body=body,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
reply_to=reply_to,
|
||||
html_body=html_body,
|
||||
in_reply_to=in_reply_to,
|
||||
references=references,
|
||||
@@ -513,10 +511,9 @@ class EmailService:
|
||||
body: Optional[str] = None,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
reply_to_email_id: Optional[str] = None,
|
||||
reply_mailbox: Optional[str] = None,
|
||||
in_reply_to_email_id: Optional[str] = None,
|
||||
in_reply_to_mailbox: Optional[str] = None,
|
||||
reply_all: bool = False,
|
||||
) -> OperationResult:
|
||||
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
|
||||
@@ -539,10 +536,10 @@ class EmailService:
|
||||
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
|
||||
|
||||
if reply_to_email_id:
|
||||
if in_reply_to_email_id:
|
||||
context, error = self._get_reply_context(
|
||||
reply_mailbox or "INBOX",
|
||||
reply_to_email_id,
|
||||
in_reply_to_mailbox or "INBOX",
|
||||
in_reply_to_email_id,
|
||||
reply_all,
|
||||
cc,
|
||||
self.settings.smtp_from_email,
|
||||
@@ -574,7 +571,6 @@ class EmailService:
|
||||
body=resolved_body,
|
||||
cc=resolved_cc,
|
||||
bcc=resolved_bcc,
|
||||
reply_to=reply_to,
|
||||
html_body=resolved_html,
|
||||
in_reply_to=in_reply_to,
|
||||
references=references,
|
||||
@@ -602,22 +598,21 @@ class EmailService:
|
||||
body: Optional[str] = None,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
sender_email: Optional[str] = None,
|
||||
sender_name: Optional[str] = None,
|
||||
in_reply_to: Optional[str] = None,
|
||||
references: Optional[list[str]] = None,
|
||||
reply_to_email_id: Optional[str] = None,
|
||||
reply_mailbox: Optional[str] = None,
|
||||
in_reply_to_email_id: Optional[str] = None,
|
||||
in_reply_to_mailbox: Optional[str] = None,
|
||||
reply_all: bool = False,
|
||||
) -> OperationResult:
|
||||
try:
|
||||
if reply_to_email_id:
|
||||
if in_reply_to_email_id:
|
||||
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
|
||||
context, error = self._get_reply_context(
|
||||
reply_mailbox or "INBOX",
|
||||
reply_to_email_id,
|
||||
in_reply_to_mailbox or "INBOX",
|
||||
in_reply_to_email_id,
|
||||
reply_all,
|
||||
cc,
|
||||
resolved_email,
|
||||
@@ -648,8 +643,6 @@ class EmailService:
|
||||
|
||||
if cc:
|
||||
msg["Cc"] = ", ".join(cc)
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to
|
||||
if references:
|
||||
@@ -687,6 +680,62 @@ class EmailService:
|
||||
except Exception as e:
|
||||
return OperationResult(success=False, message=str(e))
|
||||
|
||||
async def send_draft(
|
||||
self,
|
||||
email_id: str,
|
||||
mailbox: Optional[str] = None,
|
||||
) -> OperationResult:
|
||||
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
|
||||
try:
|
||||
draft = 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 draft:
|
||||
return OperationResult(
|
||||
success=False,
|
||||
message=f"Draft {email_id} not found in {draft_mailbox}",
|
||||
id=email_id,
|
||||
)
|
||||
|
||||
to = [addr.email for addr in draft.to_addresses]
|
||||
cc = [addr.email for addr in draft.cc_addresses]
|
||||
bcc = [addr.email for addr in draft.bcc_addresses]
|
||||
|
||||
if not to and not cc and not bcc:
|
||||
return OperationResult(
|
||||
success=False,
|
||||
message="Draft has no recipients",
|
||||
id=email_id,
|
||||
)
|
||||
|
||||
subject = draft.subject or "(No Subject)"
|
||||
body = draft.body_text or ""
|
||||
html_body = draft.body_html
|
||||
|
||||
result = await self.send_email(
|
||||
to=to or None,
|
||||
subject=subject,
|
||||
body=body,
|
||||
cc=cc or None,
|
||||
bcc=bcc or None,
|
||||
html_body=html_body,
|
||||
in_reply_to=draft.in_reply_to,
|
||||
references=draft.references or None,
|
||||
)
|
||||
if not result.success:
|
||||
return result
|
||||
|
||||
try:
|
||||
client = self._get_imap_client()
|
||||
client.select_folder(draft_mailbox)
|
||||
client.delete_messages([int(email_id)])
|
||||
client.expunge()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
async def reply_email(
|
||||
self,
|
||||
mailbox: str,
|
||||
@@ -695,7 +744,6 @@ class EmailService:
|
||||
reply_all: bool = False,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
sender_email: Optional[str] = None,
|
||||
sender_name: Optional[str] = None,
|
||||
@@ -703,12 +751,11 @@ class EmailService:
|
||||
return await self.send_email(
|
||||
body=body,
|
||||
bcc=bcc,
|
||||
reply_to=reply_to,
|
||||
html_body=html_body,
|
||||
sender_email=sender_email,
|
||||
sender_name=sender_name,
|
||||
reply_to_email_id=email_id,
|
||||
reply_mailbox=mailbox,
|
||||
in_reply_to_email_id=email_id,
|
||||
in_reply_to_mailbox=mailbox,
|
||||
reply_all=reply_all,
|
||||
cc=cc,
|
||||
)
|
||||
@@ -844,7 +891,6 @@ class EmailService:
|
||||
body: str,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
in_reply_to: Optional[str] = None,
|
||||
references: Optional[list[str]] = None,
|
||||
@@ -862,8 +908,6 @@ class EmailService:
|
||||
msg["Cc"] = ", ".join(cc)
|
||||
if bcc:
|
||||
msg["Bcc"] = ", ".join(bcc)
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to
|
||||
if references:
|
||||
|
||||
@@ -8,53 +8,41 @@ from tools.logging_utils import log_tool_call
|
||||
def register_contacts_tools(mcp: FastMCP, service: ContactsService):
|
||||
"""Register all contacts-related MCP tools."""
|
||||
|
||||
@mcp.tool(description="List all available address books from the CardDAV server.")
|
||||
@log_tool_call
|
||||
def list_addressbooks() -> list[dict]:
|
||||
"""List all address books."""
|
||||
addressbooks = service.list_addressbooks()
|
||||
return [a.model_dump() for a in addressbooks]
|
||||
|
||||
@mcp.tool(description="List contacts in an address book with optional search filtering and pagination.")
|
||||
@mcp.tool(description="List contacts in the configured address book with optional search filtering and pagination.")
|
||||
@log_tool_call
|
||||
def list_contacts(
|
||||
addressbook_id: str,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
List contacts in an address book.
|
||||
List contacts in the configured address book.
|
||||
|
||||
Args:
|
||||
addressbook_id: The address book ID (URL path) to query
|
||||
search: Optional search term to filter contacts by name or email
|
||||
limit: Maximum number of contacts to return (default: 100)
|
||||
offset: Number of contacts to skip for pagination (default: 0)
|
||||
"""
|
||||
result = service.list_contacts(addressbook_id, search, limit, offset)
|
||||
result = service.list_contacts(search=search, limit=limit, offset=offset)
|
||||
return result.model_dump()
|
||||
|
||||
@mcp.tool(description="Get detailed information about a specific contact including all fields.")
|
||||
@log_tool_call
|
||||
def get_contact(
|
||||
addressbook_id: str,
|
||||
contact_id: str,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Get a specific contact.
|
||||
|
||||
Args:
|
||||
addressbook_id: The address book containing the contact
|
||||
contact_id: The unique ID (URL) of the contact
|
||||
"""
|
||||
result = service.get_contact(addressbook_id, contact_id)
|
||||
result = service.get_contact(contact_id)
|
||||
return result.model_dump() if result else None
|
||||
|
||||
@mcp.tool(description="Create a new contact with name, emails, phones, addresses, and other details.")
|
||||
@log_tool_call
|
||||
def create_contact(
|
||||
addressbook_id: str,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
@@ -70,7 +58,6 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
|
||||
Create a new contact.
|
||||
|
||||
Args:
|
||||
addressbook_id: The address book ID to create the contact in
|
||||
first_name: Contact's first/given name
|
||||
last_name: Contact's last/family name
|
||||
display_name: Full display name (auto-generated if not provided)
|
||||
@@ -83,24 +70,23 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
|
||||
birthday: Birthday in ISO format (YYYY-MM-DD)
|
||||
"""
|
||||
result = service.create_contact(
|
||||
addressbook_id,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
emails,
|
||||
phones,
|
||||
addresses,
|
||||
organization,
|
||||
title,
|
||||
notes,
|
||||
birthday,
|
||||
addressbook_id=None,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
display_name=display_name,
|
||||
emails=emails,
|
||||
phones=phones,
|
||||
addresses=addresses,
|
||||
organization=organization,
|
||||
title=title,
|
||||
notes=notes,
|
||||
birthday=birthday,
|
||||
)
|
||||
return result.model_dump()
|
||||
|
||||
@mcp.tool(description="Update an existing contact. Only provided fields will be modified.")
|
||||
@log_tool_call
|
||||
def update_contact(
|
||||
addressbook_id: str,
|
||||
contact_id: str,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
@@ -116,7 +102,6 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
|
||||
Update an existing contact.
|
||||
|
||||
Args:
|
||||
addressbook_id: The address book containing the contact
|
||||
contact_id: The unique ID of the contact to update
|
||||
first_name: New first name (optional)
|
||||
last_name: New last name (optional)
|
||||
@@ -129,32 +114,30 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
|
||||
notes: New notes (optional)
|
||||
"""
|
||||
result = service.update_contact(
|
||||
addressbook_id,
|
||||
contact_id,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
emails,
|
||||
phones,
|
||||
addresses,
|
||||
organization,
|
||||
title,
|
||||
notes,
|
||||
addressbook_id=None,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
display_name=display_name,
|
||||
emails=emails,
|
||||
phones=phones,
|
||||
addresses=addresses,
|
||||
organization=organization,
|
||||
title=title,
|
||||
notes=notes,
|
||||
)
|
||||
return result.model_dump() if result else None
|
||||
|
||||
@mcp.tool(description="Delete a contact from an address book.")
|
||||
@log_tool_call
|
||||
def delete_contact(
|
||||
addressbook_id: str,
|
||||
contact_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a contact.
|
||||
|
||||
Args:
|
||||
addressbook_id: The address book containing the contact
|
||||
contact_id: The unique ID of the contact to delete
|
||||
"""
|
||||
result = service.delete_contact(addressbook_id, contact_id)
|
||||
result = service.delete_contact(contact_id)
|
||||
return result.model_dump()
|
||||
|
||||
@@ -153,7 +153,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
||||
result = service.delete_draft(email_id, mailbox, permanent)
|
||||
return result.model_dump()
|
||||
|
||||
@mcp.tool(description="Save a new draft email to the Drafts mailbox.")
|
||||
@mcp.tool(description="Save a new draft email to the Drafts mailbox. Supports reply threading via in_reply_to_email_id.")
|
||||
@log_tool_call
|
||||
def save_draft(
|
||||
to: Optional[list[str]] = None,
|
||||
@@ -161,27 +161,23 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
||||
body: Optional[str] = None,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
mailbox: Optional[str] = None,
|
||||
reply_to_email_id: Optional[str] = None,
|
||||
reply_mailbox: Optional[str] = None,
|
||||
in_reply_to_email_id: Optional[str] = None,
|
||||
in_reply_to_mailbox: Optional[str] = None,
|
||||
reply_all: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Save a new email draft.
|
||||
|
||||
Args:
|
||||
to: List of recipient email addresses (required unless reply_to_email_id is set)
|
||||
subject: Email subject line (required unless reply_to_email_id is set)
|
||||
body: Plain text email body (required unless reply_to_email_id is set)
|
||||
to: List of recipient email addresses (required unless in_reply_to_email_id is set)
|
||||
subject: Email subject line (required unless in_reply_to_email_id is set)
|
||||
body: Plain text email body (required unless in_reply_to_email_id is set)
|
||||
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)
|
||||
reply_to_email_id: Email ID to reply to (optional)
|
||||
reply_mailbox: Mailbox containing the reply_to_email_id (default: INBOX)
|
||||
in_reply_to_email_id: Email UID to reply to (optional, derives recipients/subject and sets threading headers)
|
||||
in_reply_to_mailbox: Mailbox containing the in_reply_to_email_id (default: INBOX)
|
||||
reply_all: Whether to include original recipients when replying (default: False)
|
||||
"""
|
||||
result = service.save_draft(
|
||||
@@ -190,16 +186,14 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
||||
body,
|
||||
cc,
|
||||
bcc,
|
||||
reply_to,
|
||||
html_body,
|
||||
mailbox,
|
||||
reply_to_email_id,
|
||||
reply_mailbox,
|
||||
in_reply_to_email_id,
|
||||
in_reply_to_mailbox,
|
||||
reply_all,
|
||||
)
|
||||
return result.model_dump()
|
||||
|
||||
@mcp.tool(description="Edit an existing draft email. Only provided fields will be modified.")
|
||||
@mcp.tool(description="Edit an existing draft email. Only provided fields will be modified. Supports reply threading via in_reply_to_email_id.")
|
||||
@log_tool_call
|
||||
def edit_draft(
|
||||
email_id: str,
|
||||
@@ -209,10 +203,8 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
||||
body: Optional[str] = None,
|
||||
cc: Optional[list[str]] = None,
|
||||
bcc: Optional[list[str]] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
html_body: Optional[str] = None,
|
||||
reply_to_email_id: Optional[str] = None,
|
||||
reply_mailbox: Optional[str] = None,
|
||||
in_reply_to_email_id: Optional[str] = None,
|
||||
in_reply_to_mailbox: Optional[str] = None,
|
||||
reply_all: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
@@ -226,10 +218,8 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
||||
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)
|
||||
reply_to_email_id: Email ID to reply to (optional)
|
||||
reply_mailbox: Mailbox containing the reply_to_email_id (default: INBOX)
|
||||
in_reply_to_email_id: Email UID to reply to (optional, derives recipients/subject and sets threading headers)
|
||||
in_reply_to_mailbox: Mailbox containing the in_reply_to_email_id (default: INBOX)
|
||||
reply_all: Whether to include original recipients when replying (default: False)
|
||||
"""
|
||||
result = service.update_draft(
|
||||
@@ -240,63 +230,26 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
||||
body,
|
||||
cc,
|
||||
bcc,
|
||||
reply_to,
|
||||
html_body,
|
||||
reply_to_email_id,
|
||||
reply_mailbox,
|
||||
in_reply_to_email_id,
|
||||
in_reply_to_mailbox,
|
||||
reply_all,
|
||||
)
|
||||
return result.model_dump()
|
||||
|
||||
@mcp.tool(description="Send a new email via SMTP. Supports plain text and HTML content, CC, BCC, reply-to, and custom sender.")
|
||||
@mcp.tool(description="Send an existing draft by ID. Only drafts can be sent.")
|
||||
@log_tool_call
|
||||
async def send_email(
|
||||
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,
|
||||
sender_email: Optional[str] = None,
|
||||
sender_name: Optional[str] = None,
|
||||
reply_to_email_id: Optional[str] = None,
|
||||
reply_mailbox: Optional[str] = None,
|
||||
reply_all: bool = False,
|
||||
async def send_draft(
|
||||
email_id: str,
|
||||
mailbox: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Send a new email.
|
||||
Send a draft email.
|
||||
|
||||
Args:
|
||||
to: List of recipient email addresses (required unless reply_to_email_id is set)
|
||||
subject: Email subject line (required unless reply_to_email_id is set)
|
||||
body: Plain text email body (required unless reply_to_email_id is set)
|
||||
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)
|
||||
sender_email: Sender email address (optional, defaults to SMTP_FROM_EMAIL)
|
||||
sender_name: Sender display name (optional, defaults to SMTP_FROM_NAME)
|
||||
reply_to_email_id: Email ID to reply to (optional)
|
||||
reply_mailbox: Mailbox containing the reply_to_email_id (default: INBOX)
|
||||
reply_all: Whether to include original recipients when replying (default: False)
|
||||
email_id: The unique ID of the draft to send
|
||||
mailbox: Drafts mailbox/folder override (default: auto-detect)
|
||||
"""
|
||||
result = await service.send_email(
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc,
|
||||
bcc,
|
||||
reply_to,
|
||||
html_body,
|
||||
sender_email,
|
||||
sender_name,
|
||||
None,
|
||||
None,
|
||||
reply_to_email_id,
|
||||
reply_mailbox,
|
||||
reply_all,
|
||||
)
|
||||
result = await service.send_draft(email_id, mailbox)
|
||||
return result.model_dump()
|
||||
|
||||
@mcp.tool(description="Set or remove IMAP flags on an email. Standard flags: \\Seen, \\Answered, \\Flagged, \\Deleted, \\Draft. Custom keywords are also supported.")
|
||||
@@ -321,7 +274,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
||||
|
||||
@mcp.tool(description="Unsubscribe from a mailing list. Parses List-Unsubscribe headers and attempts automatic unsubscribe via HTTP or provides mailto instructions.")
|
||||
@log_tool_call
|
||||
async def unsubscribe_email(
|
||||
async def unsubscribe_maillist(
|
||||
email_id: str,
|
||||
mailbox: str = "INBOX",
|
||||
) -> dict:
|
||||
|
||||
30
test.sh
30
test.sh
@@ -17,12 +17,12 @@ echo "Testing MCP server at $BASE_URL"
|
||||
echo "================================"
|
||||
|
||||
# Test health endpoint
|
||||
echo -e "\n[1/5] Testing health endpoint..."
|
||||
echo -e "\n[1/6] Testing health endpoint..."
|
||||
HEALTH=$(curl -s "$BASE_URL/health")
|
||||
echo "Response: $HEALTH"
|
||||
|
||||
# Initialize session and capture session ID
|
||||
echo -e "\n[2/5] Initializing MCP session..."
|
||||
echo -e "\n[2/6] Initializing MCP session..."
|
||||
INIT_RESPONSE=$(curl -s -D - -X POST "$MCP_ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
@@ -55,12 +55,12 @@ mcp_request() {
|
||||
}
|
||||
|
||||
# List available tools
|
||||
echo -e "\n[3/5] Listing available tools..."
|
||||
echo -e "\n[3/6] Listing available tools..."
|
||||
TOOLS=$(mcp_request 2 "tools/list" "{}")
|
||||
echo "$TOOLS" | grep -o '"name":"[^"]*"' | head -20 || echo "$TOOLS"
|
||||
|
||||
# Get server info
|
||||
echo -e "\n[4/5] Getting server info..."
|
||||
echo -e "\n[4/6] Getting server info..."
|
||||
SERVER_INFO=$(mcp_request 3 "tools/call" '{"name":"get_server_info","arguments":{}}')
|
||||
echo "$SERVER_INFO"
|
||||
|
||||
@@ -69,20 +69,18 @@ echo -e "\n[5/7] Listing mailboxes..."
|
||||
MAILBOXES=$(mcp_request 4 "tools/call" '{"name":"list_mailboxes","arguments":{}}')
|
||||
echo "$MAILBOXES"
|
||||
|
||||
# List address books
|
||||
echo -e "\n[6/7] Listing address books..."
|
||||
ADDRESSBOOKS=$(mcp_request 5 "tools/call" '{"name":"list_addressbooks","arguments":{}}')
|
||||
echo "$ADDRESSBOOKS"
|
||||
# List contacts
|
||||
echo -e "\n[6/7] Listing contacts..."
|
||||
CONTACTS=$(mcp_request 5 "tools/call" '{"name":"list_contacts","arguments":{"limit":10}}')
|
||||
echo "$CONTACTS"
|
||||
|
||||
# List contacts (using first addressbook from previous response)
|
||||
echo -e "\n[7/7] Listing contacts..."
|
||||
# Extract first addressbook ID from previous response
|
||||
ADDRESSBOOK_ID=$(echo "$ADDRESSBOOKS" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$ADDRESSBOOK_ID" ]; then
|
||||
CONTACTS=$(mcp_request 6 "tools/call" "{\"name\":\"list_contacts\",\"arguments\":{\"addressbook_id\":\"$ADDRESSBOOK_ID\",\"limit\":10}}")
|
||||
echo "$CONTACTS"
|
||||
# Draft a reply (requires REPLY_EMAIL_ID)
|
||||
echo -e "\n[7/7] Drafting reply..."
|
||||
if [ -n "$REPLY_EMAIL_ID" ]; then
|
||||
DRAFT_REPLY=$(mcp_request 6 "tools/call" "{\"name\":\"save_draft\",\"arguments\":{\"in_reply_to_email_id\":\"$REPLY_EMAIL_ID\",\"in_reply_to_mailbox\":\"INBOX\",\"reply_all\":false,\"body\":\"Test reply draft\"}}")
|
||||
echo "$DRAFT_REPLY"
|
||||
else
|
||||
echo "No addressbook found to list contacts from"
|
||||
echo "Skipping reply draft: set REPLY_EMAIL_ID to an email UID to test threading"
|
||||
fi
|
||||
|
||||
echo -e "\n================================"
|
||||
|
||||
Reference in New Issue
Block a user