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_USERNAME=user@example.com
CARDDAV_PASSWORD=your-carddav-password
CONTACTS_ADDRESSBOOK_ID=/dav/addressbooks/users/user@example.com/contacts/
# =============================================================================
# Cache Configuration

View File

@@ -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.

View File

@@ -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:

View File

@@ -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")

View File

@@ -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:

View File

@@ -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()

View File

@@ -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
View File

@@ -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================================"