From 5a9ef0e48ffea083e16464d4f48bdeeac9e97a1c Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Thu, 1 Jan 2026 15:46:44 -0800 Subject: [PATCH] Revise contacts and email tools --- .env.example | 1 + README.md | 34 ++++++++--- src/config.py | 5 ++ src/services/contacts_service.py | 25 +++++--- src/services/email_service.py | 102 ++++++++++++++++++++++--------- src/tools/contacts_tools.py | 69 ++++++++------------- src/tools/email_tools.py | 99 ++++++++---------------------- test.sh | 30 +++++---- 8 files changed, 189 insertions(+), 176 deletions(-) diff --git a/.env.example b/.env.example index 4848bee..d80b8c7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index c666d7b..c446060 100644 --- a/README.md +++ b/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. diff --git a/src/config.py b/src/config.py index 026083a..e7eafbc 100644 --- a/src/config.py +++ b/src/config.py @@ -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: diff --git a/src/services/contacts_service.py b/src/services/contacts_service.py index 69b0cfd..4d9de6b 100644 --- a/src/services/contacts_service.py +++ b/src/services/contacts_service.py @@ -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") diff --git a/src/services/email_service.py b/src/services/email_service.py index 2af7e06..7bf9cd2 100644 --- a/src/services/email_service.py +++ b/src/services/email_service.py @@ -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: diff --git a/src/tools/contacts_tools.py b/src/tools/contacts_tools.py index 931dba5..a18e34c 100644 --- a/src/tools/contacts_tools.py +++ b/src/tools/contacts_tools.py @@ -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() diff --git a/src/tools/email_tools.py b/src/tools/email_tools.py index 8edc3dc..f77e2c7 100644 --- a/src/tools/email_tools.py +++ b/src/tools/email_tools.py @@ -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: diff --git a/test.sh b/test.sh index b12f919..aef459e 100755 --- a/test.sh +++ b/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================================"