Revise contacts and email tools
All checks were successful
Build And Test / publish (push) Successful in 48s

This commit is contained in:
2026-01-01 15:46:44 -08:00
parent 767f076048
commit 5a9ef0e48f
8 changed files with 189 additions and 176 deletions

View File

@@ -54,6 +54,7 @@ CALDAV_PASSWORD=your-caldav-password
CARDDAV_URL=https://carddav.example.com/dav CARDDAV_URL=https://carddav.example.com/dav
CARDDAV_USERNAME=user@example.com CARDDAV_USERNAME=user@example.com
CARDDAV_PASSWORD=your-carddav-password CARDDAV_PASSWORD=your-carddav-password
CONTACTS_ADDRESSBOOK_ID=/dav/addressbooks/users/user@example.com/contacts/
# ============================================================================= # =============================================================================
# Cache Configuration # Cache Configuration

View File

@@ -4,7 +4,7 @@ A self-hosted MCP server that connects IMAP/SMTP, CalDAV, and CardDAV to MCP-com
## Features ## 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) - Calendar tools over CalDAV (list, create, update, delete)
- Contacts tools over CardDAV (list, create, update, delete) - Contacts tools over CardDAV (list, create, update, delete)
- Optional email notifications via webhook with IMAP IDLE or polling - 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_URL=https://carddav.example.com/dav
CARDDAV_USERNAME=you@example.com CARDDAV_USERNAME=you@example.com
CARDDAV_PASSWORD=your-password 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. 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) ### Email notifications (Poke webhook)
@@ -145,32 +148,47 @@ Add your MCP endpoint at https://poke.com/settings/connections.
| Category | Tools | | 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` | | 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` | | 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 ### 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. - `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. - 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): Example (send a reply):
```json ```json
{ {
"tool": "send_email", "tool": "save_draft",
"args": { "args": {
"reply_to_email_id": "12345", "in_reply_to_email_id": "12345",
"reply_mailbox": "INBOX", "in_reply_to_mailbox": "INBOX",
"reply_all": true, "reply_all": true,
"body": "Thanks — sounds good to me." "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 ## Database and Migrations
The server uses SQLite (default: `/data/cache.db`) and Alembic. The server uses SQLite (default: `/data/cache.db`) and Alembic.

View File

@@ -40,6 +40,10 @@ class Settings(BaseSettings):
carddav_url: Optional[str] = Field(default=None, alias="CARDDAV_URL") carddav_url: Optional[str] = Field(default=None, alias="CARDDAV_URL")
carddav_username: Optional[str] = Field(default=None, alias="CARDDAV_USERNAME") carddav_username: Optional[str] = Field(default=None, alias="CARDDAV_USERNAME")
carddav_password: Optional[SecretStr] = Field(default=None, alias="CARDDAV_PASSWORD") 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 Cache
sqlite_path: str = Field(default="/data/cache.db", alias="SQLITE_PATH") sqlite_path: str = Field(default="/data/cache.db", alias="SQLITE_PATH")
@@ -150,6 +154,7 @@ class Settings(BaseSettings):
self.carddav_url, self.carddav_url,
self.carddav_username, self.carddav_username,
self.carddav_password, self.carddav_password,
self.contacts_addressbook_id,
]) ])
def is_notification_configured(self) -> bool: def is_notification_configured(self) -> bool:

View File

@@ -114,12 +114,13 @@ class ContactsService:
def list_contacts( def list_contacts(
self, self,
addressbook_id: str, addressbook_id: Optional[str] = None,
search: Optional[str] = None, search: Optional[str] = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
) -> ContactList: ) -> ContactList:
client = self._get_client() client = self._get_client()
addressbook_id = self._resolve_addressbook_id(addressbook_id)
# Build URL # Build URL
addressbook_url = self._build_url(addressbook_id) addressbook_url = self._build_url(addressbook_id)
@@ -188,8 +189,9 @@ class ContactsService:
offset=offset, 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() client = self._get_client()
addressbook_id = self._resolve_addressbook_id(addressbook_id)
# Build URL # Build URL
contact_url = self._build_url(contact_id) contact_url = self._build_url(contact_id)
@@ -206,7 +208,7 @@ class ContactsService:
def create_contact( def create_contact(
self, self,
addressbook_id: str, addressbook_id: Optional[str] = None,
first_name: Optional[str] = None, first_name: Optional[str] = None,
last_name: Optional[str] = None, last_name: Optional[str] = None,
display_name: Optional[str] = None, display_name: Optional[str] = None,
@@ -219,6 +221,7 @@ class ContactsService:
birthday: Optional[str] = None, birthday: Optional[str] = None,
) -> Contact: ) -> Contact:
client = self._get_client() client = self._get_client()
addressbook_id = self._resolve_addressbook_id(addressbook_id)
# Create vCard # Create vCard
vcard = vobject.vCard() vcard = vobject.vCard()
@@ -311,8 +314,8 @@ class ContactsService:
def update_contact( def update_contact(
self, self,
addressbook_id: str,
contact_id: str, contact_id: str,
addressbook_id: Optional[str] = None,
first_name: Optional[str] = None, first_name: Optional[str] = None,
last_name: Optional[str] = None, last_name: Optional[str] = None,
display_name: Optional[str] = None, display_name: Optional[str] = None,
@@ -324,7 +327,7 @@ class ContactsService:
notes: Optional[str] = None, notes: Optional[str] = None,
) -> Optional[Contact]: ) -> Optional[Contact]:
# Get existing contact # Get existing contact
existing = self.get_contact(addressbook_id, contact_id) existing = self.get_contact(contact_id, addressbook_id)
if not existing: if not existing:
return None return None
@@ -342,12 +345,13 @@ class ContactsService:
} }
# Delete and recreate (simpler than partial update) # 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) 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: try:
client = self._get_client() client = self._get_client()
addressbook_id = self._resolve_addressbook_id(addressbook_id)
# Build URL # Build URL
contact_url = self._build_url(contact_id) contact_url = self._build_url(contact_id)
@@ -481,3 +485,10 @@ class ContactsService:
notes=notes, notes=notes,
birthday=birthday, 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")

View File

@@ -438,19 +438,18 @@ class EmailService:
body: Optional[str] = None, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
mailbox: Optional[str] = None, mailbox: Optional[str] = None,
reply_to_email_id: Optional[str] = None, in_reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None, in_reply_to_mailbox: Optional[str] = None,
reply_all: bool = False, reply_all: bool = False,
) -> OperationResult: ) -> OperationResult:
try: try:
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts" 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( context, error = self._get_reply_context(
reply_mailbox or "INBOX", in_reply_to_mailbox or "INBOX",
reply_to_email_id, in_reply_to_email_id,
reply_all, reply_all,
cc, cc,
self.settings.smtp_from_email, self.settings.smtp_from_email,
@@ -482,7 +481,6 @@ class EmailService:
body=body, body=body,
cc=cc, cc=cc,
bcc=bcc, bcc=bcc,
reply_to=reply_to,
html_body=html_body, html_body=html_body,
in_reply_to=in_reply_to, in_reply_to=in_reply_to,
references=references, references=references,
@@ -513,10 +511,9 @@ class EmailService:
body: Optional[str] = None, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
reply_to_email_id: Optional[str] = None, in_reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None, in_reply_to_mailbox: Optional[str] = None,
reply_all: bool = False, reply_all: bool = False,
) -> OperationResult: ) -> OperationResult:
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts" 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_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 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( context, error = self._get_reply_context(
reply_mailbox or "INBOX", in_reply_to_mailbox or "INBOX",
reply_to_email_id, in_reply_to_email_id,
reply_all, reply_all,
cc, cc,
self.settings.smtp_from_email, self.settings.smtp_from_email,
@@ -574,7 +571,6 @@ class EmailService:
body=resolved_body, body=resolved_body,
cc=resolved_cc, cc=resolved_cc,
bcc=resolved_bcc, bcc=resolved_bcc,
reply_to=reply_to,
html_body=resolved_html, html_body=resolved_html,
in_reply_to=in_reply_to, in_reply_to=in_reply_to,
references=references, references=references,
@@ -602,22 +598,21 @@ class EmailService:
body: Optional[str] = None, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
sender_email: Optional[str] = None, sender_email: Optional[str] = None,
sender_name: Optional[str] = None, sender_name: Optional[str] = None,
in_reply_to: Optional[str] = None, in_reply_to: Optional[str] = None,
references: Optional[list[str]] = None, references: Optional[list[str]] = None,
reply_to_email_id: Optional[str] = None, in_reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None, in_reply_to_mailbox: Optional[str] = None,
reply_all: bool = False, reply_all: bool = False,
) -> OperationResult: ) -> OperationResult:
try: try:
if reply_to_email_id: if in_reply_to_email_id:
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name) resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
context, error = self._get_reply_context( context, error = self._get_reply_context(
reply_mailbox or "INBOX", in_reply_to_mailbox or "INBOX",
reply_to_email_id, in_reply_to_email_id,
reply_all, reply_all,
cc, cc,
resolved_email, resolved_email,
@@ -648,8 +643,6 @@ class EmailService:
if cc: if cc:
msg["Cc"] = ", ".join(cc) msg["Cc"] = ", ".join(cc)
if reply_to:
msg["Reply-To"] = reply_to
if in_reply_to: if in_reply_to:
msg["In-Reply-To"] = in_reply_to msg["In-Reply-To"] = in_reply_to
if references: if references:
@@ -687,6 +680,62 @@ class EmailService:
except Exception as e: except Exception as e:
return OperationResult(success=False, message=str(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( async def reply_email(
self, self,
mailbox: str, mailbox: str,
@@ -695,7 +744,6 @@ class EmailService:
reply_all: bool = False, reply_all: bool = False,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
sender_email: Optional[str] = None, sender_email: Optional[str] = None,
sender_name: Optional[str] = None, sender_name: Optional[str] = None,
@@ -703,12 +751,11 @@ class EmailService:
return await self.send_email( return await self.send_email(
body=body, body=body,
bcc=bcc, bcc=bcc,
reply_to=reply_to,
html_body=html_body, html_body=html_body,
sender_email=sender_email, sender_email=sender_email,
sender_name=sender_name, sender_name=sender_name,
reply_to_email_id=email_id, in_reply_to_email_id=email_id,
reply_mailbox=mailbox, in_reply_to_mailbox=mailbox,
reply_all=reply_all, reply_all=reply_all,
cc=cc, cc=cc,
) )
@@ -844,7 +891,6 @@ class EmailService:
body: str, body: str,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
in_reply_to: Optional[str] = None, in_reply_to: Optional[str] = None,
references: Optional[list[str]] = None, references: Optional[list[str]] = None,
@@ -862,8 +908,6 @@ class EmailService:
msg["Cc"] = ", ".join(cc) msg["Cc"] = ", ".join(cc)
if bcc: if bcc:
msg["Bcc"] = ", ".join(bcc) msg["Bcc"] = ", ".join(bcc)
if reply_to:
msg["Reply-To"] = reply_to
if in_reply_to: if in_reply_to:
msg["In-Reply-To"] = in_reply_to msg["In-Reply-To"] = in_reply_to
if references: if references:

View File

@@ -8,53 +8,41 @@ from tools.logging_utils import log_tool_call
def register_contacts_tools(mcp: FastMCP, service: ContactsService): def register_contacts_tools(mcp: FastMCP, service: ContactsService):
"""Register all contacts-related MCP tools.""" """Register all contacts-related MCP tools."""
@mcp.tool(description="List all available address books from the CardDAV server.") @mcp.tool(description="List contacts in the configured address book with optional search filtering and pagination.")
@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.")
@log_tool_call @log_tool_call
def list_contacts( def list_contacts(
addressbook_id: str,
search: Optional[str] = None, search: Optional[str] = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
) -> dict: ) -> dict:
""" """
List contacts in an address book. List contacts in the configured address book.
Args: Args:
addressbook_id: The address book ID (URL path) to query
search: Optional search term to filter contacts by name or email search: Optional search term to filter contacts by name or email
limit: Maximum number of contacts to return (default: 100) limit: Maximum number of contacts to return (default: 100)
offset: Number of contacts to skip for pagination (default: 0) 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() return result.model_dump()
@mcp.tool(description="Get detailed information about a specific contact including all fields.") @mcp.tool(description="Get detailed information about a specific contact including all fields.")
@log_tool_call @log_tool_call
def get_contact( def get_contact(
addressbook_id: str,
contact_id: str, contact_id: str,
) -> Optional[dict]: ) -> Optional[dict]:
""" """
Get a specific contact. Get a specific contact.
Args: Args:
addressbook_id: The address book containing the contact
contact_id: The unique ID (URL) of 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 return result.model_dump() if result else None
@mcp.tool(description="Create a new contact with name, emails, phones, addresses, and other details.") @mcp.tool(description="Create a new contact with name, emails, phones, addresses, and other details.")
@log_tool_call @log_tool_call
def create_contact( def create_contact(
addressbook_id: str,
first_name: Optional[str] = None, first_name: Optional[str] = None,
last_name: Optional[str] = None, last_name: Optional[str] = None,
display_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. Create a new contact.
Args: Args:
addressbook_id: The address book ID to create the contact in
first_name: Contact's first/given name first_name: Contact's first/given name
last_name: Contact's last/family name last_name: Contact's last/family name
display_name: Full display name (auto-generated if not provided) 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) birthday: Birthday in ISO format (YYYY-MM-DD)
""" """
result = service.create_contact( result = service.create_contact(
addressbook_id, addressbook_id=None,
first_name, first_name=first_name,
last_name, last_name=last_name,
display_name, display_name=display_name,
emails, emails=emails,
phones, phones=phones,
addresses, addresses=addresses,
organization, organization=organization,
title, title=title,
notes, notes=notes,
birthday, birthday=birthday,
) )
return result.model_dump() return result.model_dump()
@mcp.tool(description="Update an existing contact. Only provided fields will be modified.") @mcp.tool(description="Update an existing contact. Only provided fields will be modified.")
@log_tool_call @log_tool_call
def update_contact( def update_contact(
addressbook_id: str,
contact_id: str, contact_id: str,
first_name: Optional[str] = None, first_name: Optional[str] = None,
last_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. Update an existing contact.
Args: Args:
addressbook_id: The address book containing the contact
contact_id: The unique ID of the contact to update contact_id: The unique ID of the contact to update
first_name: New first name (optional) first_name: New first name (optional)
last_name: New last name (optional) last_name: New last name (optional)
@@ -129,32 +114,30 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
notes: New notes (optional) notes: New notes (optional)
""" """
result = service.update_contact( result = service.update_contact(
addressbook_id,
contact_id, contact_id,
first_name, addressbook_id=None,
last_name, first_name=first_name,
display_name, last_name=last_name,
emails, display_name=display_name,
phones, emails=emails,
addresses, phones=phones,
organization, addresses=addresses,
title, organization=organization,
notes, title=title,
notes=notes,
) )
return result.model_dump() if result else None return result.model_dump() if result else None
@mcp.tool(description="Delete a contact from an address book.") @mcp.tool(description="Delete a contact from an address book.")
@log_tool_call @log_tool_call
def delete_contact( def delete_contact(
addressbook_id: str,
contact_id: str, contact_id: str,
) -> dict: ) -> dict:
""" """
Delete a contact. Delete a contact.
Args: Args:
addressbook_id: The address book containing the contact
contact_id: The unique ID of the contact to delete 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() return result.model_dump()

View File

@@ -153,7 +153,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
result = service.delete_draft(email_id, mailbox, permanent) result = service.delete_draft(email_id, mailbox, permanent)
return result.model_dump() 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 @log_tool_call
def save_draft( def save_draft(
to: Optional[list[str]] = None, to: Optional[list[str]] = None,
@@ -161,27 +161,23 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
body: Optional[str] = None, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None,
mailbox: Optional[str] = None, mailbox: Optional[str] = None,
reply_to_email_id: Optional[str] = None, in_reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None, in_reply_to_mailbox: Optional[str] = None,
reply_all: bool = False, reply_all: bool = False,
) -> dict: ) -> dict:
""" """
Save a new email draft. Save a new email draft.
Args: Args:
to: List of recipient email addresses (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 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 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) cc: List of CC recipients (optional)
bcc: List of BCC 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) mailbox: Drafts mailbox/folder override (default: auto-detect)
reply_to_email_id: Email ID to reply to (optional) in_reply_to_email_id: Email UID to reply to (optional, derives recipients/subject and sets threading headers)
reply_mailbox: Mailbox containing the reply_to_email_id (default: INBOX) 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) reply_all: Whether to include original recipients when replying (default: False)
""" """
result = service.save_draft( result = service.save_draft(
@@ -190,16 +186,14 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
body, body,
cc, cc,
bcc, bcc,
reply_to,
html_body,
mailbox, mailbox,
reply_to_email_id, in_reply_to_email_id,
reply_mailbox, in_reply_to_mailbox,
reply_all, reply_all,
) )
return result.model_dump() 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 @log_tool_call
def edit_draft( def edit_draft(
email_id: str, email_id: str,
@@ -209,10 +203,8 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
body: Optional[str] = None, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, in_reply_to_email_id: Optional[str] = None,
html_body: Optional[str] = None, in_reply_to_mailbox: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False, reply_all: bool = False,
) -> dict: ) -> dict:
""" """
@@ -226,10 +218,8 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
body: Plain text email body body: Plain text email body
cc: List of CC recipients (optional) cc: List of CC recipients (optional)
bcc: List of BCC recipients (optional) bcc: List of BCC recipients (optional)
reply_to: Reply-to address (optional) in_reply_to_email_id: Email UID to reply to (optional, derives recipients/subject and sets threading headers)
html_body: HTML version of the email body (optional) in_reply_to_mailbox: Mailbox containing the in_reply_to_email_id (default: INBOX)
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) reply_all: Whether to include original recipients when replying (default: False)
""" """
result = service.update_draft( result = service.update_draft(
@@ -240,63 +230,26 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
body, body,
cc, cc,
bcc, bcc,
reply_to, in_reply_to_email_id,
html_body, in_reply_to_mailbox,
reply_to_email_id,
reply_mailbox,
reply_all, reply_all,
) )
return result.model_dump() 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 @log_tool_call
async def send_email( async def send_draft(
to: Optional[list[str]] = None, email_id: str,
subject: Optional[str] = None, mailbox: 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,
) -> dict: ) -> dict:
""" """
Send a new email. Send a draft email.
Args: Args:
to: List of recipient email addresses (required unless reply_to_email_id is set) email_id: The unique ID of the draft to send
subject: Email subject line (required unless reply_to_email_id is set) mailbox: Drafts mailbox/folder override (default: auto-detect)
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)
""" """
result = await service.send_email( result = await service.send_draft(email_id, mailbox)
to,
subject,
body,
cc,
bcc,
reply_to,
html_body,
sender_email,
sender_name,
None,
None,
reply_to_email_id,
reply_mailbox,
reply_all,
)
return result.model_dump() 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.") @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.") @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 @log_tool_call
async def unsubscribe_email( async def unsubscribe_maillist(
email_id: str, email_id: str,
mailbox: str = "INBOX", mailbox: str = "INBOX",
) -> dict: ) -> dict:

30
test.sh
View File

@@ -17,12 +17,12 @@ echo "Testing MCP server at $BASE_URL"
echo "================================" echo "================================"
# Test health endpoint # 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") HEALTH=$(curl -s "$BASE_URL/health")
echo "Response: $HEALTH" echo "Response: $HEALTH"
# Initialize session and capture session ID # 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" \ INIT_RESPONSE=$(curl -s -D - -X POST "$MCP_ENDPOINT" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \ -H "Accept: application/json, text/event-stream" \
@@ -55,12 +55,12 @@ mcp_request() {
} }
# List available tools # 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" "{}") TOOLS=$(mcp_request 2 "tools/list" "{}")
echo "$TOOLS" | grep -o '"name":"[^"]*"' | head -20 || echo "$TOOLS" echo "$TOOLS" | grep -o '"name":"[^"]*"' | head -20 || echo "$TOOLS"
# Get server info # 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":{}}') SERVER_INFO=$(mcp_request 3 "tools/call" '{"name":"get_server_info","arguments":{}}')
echo "$SERVER_INFO" echo "$SERVER_INFO"
@@ -69,20 +69,18 @@ echo -e "\n[5/7] Listing mailboxes..."
MAILBOXES=$(mcp_request 4 "tools/call" '{"name":"list_mailboxes","arguments":{}}') MAILBOXES=$(mcp_request 4 "tools/call" '{"name":"list_mailboxes","arguments":{}}')
echo "$MAILBOXES" echo "$MAILBOXES"
# List address books # List contacts
echo -e "\n[6/7] Listing address books..." echo -e "\n[6/7] Listing contacts..."
ADDRESSBOOKS=$(mcp_request 5 "tools/call" '{"name":"list_addressbooks","arguments":{}}') CONTACTS=$(mcp_request 5 "tools/call" '{"name":"list_contacts","arguments":{"limit":10}}')
echo "$ADDRESSBOOKS"
# 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" 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 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 fi
echo -e "\n================================" echo -e "\n================================"