From 71c55f7289265d0897eefdc879d88d0a532e9b5b Mon Sep 17 00:00:00 2001 From: Yigit Colakoglu Date: Thu, 1 Jan 2026 12:16:06 -0800 Subject: [PATCH] Add reply_email tool and sender override --- README.md | 2 +- src/services/email_service.py | 115 +++++++++++++++++++++++++++++++++- src/tools/email_tools.py | 60 +++++++++++++++++- 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6871562..f18758c 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ 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_email`, `reply_email`, `set_email_flags`, `unsubscribe_email` | | 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` | | System | `get_server_info` | diff --git a/src/services/email_service.py b/src/services/email_service.py index 9c332e5..e3fafe9 100644 --- a/src/services/email_service.py +++ b/src/services/email_service.py @@ -543,19 +543,26 @@ class EmailService: 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, ) -> OperationResult: try: msg = MIMEMultipart("alternative") msg["Subject"] = subject - msg["From"] = formataddr( - (self.settings.smtp_from_name or "", self.settings.smtp_from_email) - ) + resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name) + msg["From"] = formataddr((resolved_name or "", resolved_email)) msg["To"] = ", ".join(to) 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: + msg["References"] = " ".join(references) # Add plain text body msg.attach(MIMEText(body, "plain", "utf-8")) @@ -589,6 +596,82 @@ class EmailService: except Exception as e: return OperationResult(success=False, message=str(e)) + async def reply_email( + self, + mailbox: str, + email_id: str, + body: str, + 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, + ) -> OperationResult: + original = self.read_email(mailbox, email_id, format="both") + if not original: + return OperationResult( + success=False, + message=f"Email {email_id} not found in {mailbox}", + id=email_id, + ) + + resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name) + reply_to_header = original.headers.get("Reply-To") + reply_to_email = None + if reply_to_header: + _, reply_to_email = parseaddr(reply_to_header) + if not reply_to_email: + reply_to_email = original.from_address.email + + to = [reply_to_email] if reply_to_email else [] + reply_cc: list[str] = [] + + if reply_all: + for addr in original.to_addresses + original.cc_addresses: + if addr.email and addr.email not in to: + reply_cc.append(addr.email) + + if cc: + reply_cc.extend(cc) + + to = self._dedupe_emails(to, resolved_email) + reply_cc = self._dedupe_emails(reply_cc, resolved_email) + + if not to and reply_cc: + to = [reply_cc.pop(0)] + + if not to: + return OperationResult( + success=False, + message="No valid recipients found for reply", + id=email_id, + ) + + subject = original.subject or "(No Subject)" + if not subject.lower().startswith("re:"): + subject = f"Re: {subject}" + + in_reply_to = original.headers.get("Message-ID") or original.in_reply_to + references = list(original.references) + if in_reply_to and in_reply_to not in references: + references.append(in_reply_to) + + return await self.send_email( + to=to, + subject=subject, + body=body, + cc=reply_cc or None, + bcc=bcc, + reply_to=reply_to, + html_body=html_body, + sender_email=resolved_email, + sender_name=resolved_name, + in_reply_to=in_reply_to, + references=references or None, + ) + def _parse_envelope_addresses(self, addresses) -> list[EmailAddress]: if not addresses: return [] @@ -744,6 +827,32 @@ class EmailService: msg.attach(MIMEText(html_body, "html", "utf-8")) return msg + def _resolve_sender( + self, sender_email: Optional[str], sender_name: Optional[str] + ) -> tuple[Optional[str], str]: + if sender_email: + return sender_name, sender_email + if sender_name: + name, email_addr = parseaddr(sender_name) + if email_addr: + return name or None, email_addr + return self.settings.smtp_from_name, self.settings.smtp_from_email + + def _dedupe_emails(self, emails: list[str], self_email: Optional[str]) -> list[str]: + seen = set() + cleaned = [] + for addr in emails: + if not addr: + continue + addr_lower = addr.lower() + if self_email and addr_lower == self_email.lower(): + continue + if addr_lower in seen: + continue + seen.add(addr_lower) + cleaned.append(addr) + return cleaned + def set_flags( self, email_id: str, diff --git a/src/tools/email_tools.py b/src/tools/email_tools.py index e27f496..b0de1cb 100644 --- a/src/tools/email_tools.py +++ b/src/tools/email_tools.py @@ -210,7 +210,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService): ) return result.model_dump() - @mcp.tool(description="Send a new email via SMTP. Supports plain text and HTML content, CC, BCC, and reply-to.") + @mcp.tool(description="Send a new email via SMTP. Supports plain text and HTML content, CC, BCC, reply-to, and custom sender.") async def send_email( to: list[str], subject: str, @@ -219,6 +219,8 @@ def register_email_tools(mcp: FastMCP, service: EmailService): 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, ) -> dict: """ Send a new email. @@ -231,8 +233,62 @@ def register_email_tools(mcp: FastMCP, service: EmailService): 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) """ - result = await service.send_email(to, subject, body, cc, bcc, reply_to, html_body) + result = await service.send_email( + to, + subject, + body, + cc, + bcc, + reply_to, + html_body, + sender_email, + sender_name, + ) + return result.model_dump() + + @mcp.tool(description="Reply to an existing email by ID, with optional reply-all behavior.") + async def reply_email( + email_id: str, + body: str, + mailbox: str = "INBOX", + 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, + ) -> dict: + """ + Reply to an existing email. + + Args: + email_id: The unique ID of the email to reply to + body: Plain text email body + mailbox: The mailbox containing the email (default: INBOX) + reply_all: Whether to include original recipients (default: False) + cc: List of CC recipients (optional) + bcc: List of BCC recipients (optional) + reply_to: Reply-to address for the reply (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) + """ + result = await service.reply_email( + mailbox, + email_id, + body, + reply_all, + cc, + bcc, + reply_to, + html_body, + sender_email, + sender_name, + ) 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.")