Add reply_email tool and sender override
All checks were successful
Build And Test / publish (push) Successful in 1m0s

This commit is contained in:
2026-01-01 12:16:06 -08:00
parent 0459fb1d4c
commit 71c55f7289
3 changed files with 171 additions and 6 deletions

View File

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

View File

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

View File

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