Add email flags and unsubscribe features
All checks were successful
Build And Test / publish (push) Successful in 48s
All checks were successful
Build And Test / publish (push) Successful in 48s
- Add set_email_flags tool for IMAP flags (standard + custom keywords) - Add unsubscribe_email tool with List-Unsubscribe header parsing - Support RFC 8058 one-click unsubscribe - Add UnsubscribeInfo model to email responses - Add data field to OperationResult for extra context - Include test.sh script for MCP server testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
class OperationResult(BaseModel):
|
class OperationResult(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
data: Optional[dict[str, Any]] = None
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ class Attachment(BaseModel):
|
|||||||
content_id: Optional[str] = None
|
content_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UnsubscribeInfo(BaseModel):
|
||||||
|
"""Information about how to unsubscribe from a mailing list."""
|
||||||
|
available: bool = False
|
||||||
|
http_url: Optional[str] = None
|
||||||
|
mailto: Optional[str] = None
|
||||||
|
one_click: bool = False # RFC 8058 one-click unsubscribe supported
|
||||||
|
|
||||||
|
|
||||||
class EmailSummary(BaseModel):
|
class EmailSummary(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
mailbox: str
|
mailbox: str
|
||||||
@@ -45,6 +53,7 @@ class Email(EmailSummary):
|
|||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
in_reply_to: Optional[str] = None
|
in_reply_to: Optional[str] = None
|
||||||
references: list[str] = []
|
references: list[str] = []
|
||||||
|
unsubscribe: Optional[UnsubscribeInfo] = None
|
||||||
|
|
||||||
|
|
||||||
class EmailList(BaseModel):
|
class EmailList(BaseModel):
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ from models.email_models import (
|
|||||||
EmailSummary,
|
EmailSummary,
|
||||||
Email,
|
Email,
|
||||||
EmailList,
|
EmailList,
|
||||||
|
UnsubscribeInfo,
|
||||||
)
|
)
|
||||||
from models.common import OperationResult
|
from models.common import OperationResult
|
||||||
from config import Settings
|
from config import Settings
|
||||||
|
from services.unsubscribe_service import parse_unsubscribe_header, execute_unsubscribe
|
||||||
|
|
||||||
|
|
||||||
def decode_mime_header(header) -> str:
|
def decode_mime_header(header) -> str:
|
||||||
@@ -239,11 +241,14 @@ class EmailService:
|
|||||||
|
|
||||||
# Get headers
|
# Get headers
|
||||||
headers = {}
|
headers = {}
|
||||||
for key in ["Message-ID", "In-Reply-To", "References", "X-Priority"]:
|
for key in ["Message-ID", "In-Reply-To", "References", "X-Priority", "List-Unsubscribe", "List-Unsubscribe-Post"]:
|
||||||
value = msg.get(key)
|
value = msg.get(key)
|
||||||
if value:
|
if value:
|
||||||
headers[key] = decode_mime_header(value)
|
headers[key] = decode_mime_header(value)
|
||||||
|
|
||||||
|
# Parse unsubscribe info
|
||||||
|
unsubscribe_info = parse_unsubscribe_header(msg)
|
||||||
|
|
||||||
return Email(
|
return Email(
|
||||||
id=str(uid),
|
id=str(uid),
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
@@ -262,6 +267,7 @@ class EmailService:
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
in_reply_to=headers.get("In-Reply-To"),
|
in_reply_to=headers.get("In-Reply-To"),
|
||||||
references=headers.get("References", "").split() if headers.get("References") else [],
|
references=headers.get("References", "").split() if headers.get("References") else [],
|
||||||
|
unsubscribe=unsubscribe_info if unsubscribe_info.available else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def search_emails(
|
def search_emails(
|
||||||
@@ -568,3 +574,76 @@ class EmailService:
|
|||||||
if name in trash_names or b"\\Trash" in flags:
|
if name in trash_names or b"\\Trash" in flags:
|
||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def set_flags(
|
||||||
|
self,
|
||||||
|
email_id: str,
|
||||||
|
mailbox: str,
|
||||||
|
add_flags: Optional[list[str]] = None,
|
||||||
|
remove_flags: Optional[list[str]] = None,
|
||||||
|
) -> OperationResult:
|
||||||
|
"""
|
||||||
|
Set or remove IMAP flags on an email.
|
||||||
|
|
||||||
|
Standard flags: \\Seen, \\Answered, \\Flagged, \\Deleted, \\Draft
|
||||||
|
Custom keywords are also supported (server-dependent).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: The unique ID of the email
|
||||||
|
mailbox: The mailbox containing the email
|
||||||
|
add_flags: List of flags to add
|
||||||
|
remove_flags: List of flags to remove
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = self._get_imap_client()
|
||||||
|
client.select_folder(mailbox)
|
||||||
|
uid = int(email_id)
|
||||||
|
|
||||||
|
if add_flags:
|
||||||
|
client.add_flags([uid], add_flags)
|
||||||
|
if remove_flags:
|
||||||
|
client.remove_flags([uid], remove_flags)
|
||||||
|
|
||||||
|
return OperationResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Flags updated for email {email_id}",
|
||||||
|
id=email_id,
|
||||||
|
data={
|
||||||
|
"added": add_flags or [],
|
||||||
|
"removed": remove_flags or [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return OperationResult(success=False, message=str(e))
|
||||||
|
|
||||||
|
async def unsubscribe(
|
||||||
|
self,
|
||||||
|
mailbox: str,
|
||||||
|
email_id: str,
|
||||||
|
) -> OperationResult:
|
||||||
|
"""
|
||||||
|
Attempt to unsubscribe from a mailing list based on email headers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mailbox: The mailbox containing the email
|
||||||
|
email_id: The unique ID of the email
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OperationResult with unsubscribe status
|
||||||
|
"""
|
||||||
|
# First, read the email to get unsubscribe info
|
||||||
|
email_data = self.read_email(mailbox, email_id)
|
||||||
|
if not email_data:
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Email {email_id} not found in {mailbox}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not email_data.unsubscribe:
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message="This email does not have unsubscribe information"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute unsubscribe
|
||||||
|
return await execute_unsubscribe(email_data.unsubscribe)
|
||||||
|
|||||||
191
src/services/unsubscribe_service.py
Normal file
191
src/services/unsubscribe_service.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""Service for parsing and executing email unsubscribe actions."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from email.message import Message
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from models.email_models import UnsubscribeInfo
|
||||||
|
from models.common import OperationResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_unsubscribe_header(msg: Message) -> UnsubscribeInfo:
|
||||||
|
"""
|
||||||
|
Parse List-Unsubscribe and List-Unsubscribe-Post headers from an email.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- RFC 2369: List-Unsubscribe header with mailto: and http(s): URLs
|
||||||
|
- RFC 8058: One-Click Unsubscribe via List-Unsubscribe-Post header
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: The email message to parse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UnsubscribeInfo with available unsubscribe methods
|
||||||
|
"""
|
||||||
|
info = UnsubscribeInfo()
|
||||||
|
|
||||||
|
list_unsub = msg.get("List-Unsubscribe", "")
|
||||||
|
list_unsub_post = msg.get("List-Unsubscribe-Post", "")
|
||||||
|
|
||||||
|
if not list_unsub:
|
||||||
|
return info
|
||||||
|
|
||||||
|
# Parse URLs from List-Unsubscribe header
|
||||||
|
# Format: <url1>, <url2> or <url>
|
||||||
|
urls = re.findall(r'<([^>]+)>', list_unsub)
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
url = url.strip()
|
||||||
|
if url.startswith("mailto:"):
|
||||||
|
info.mailto = url
|
||||||
|
info.available = True
|
||||||
|
elif url.startswith("http://") or url.startswith("https://"):
|
||||||
|
info.http_url = url
|
||||||
|
info.available = True
|
||||||
|
|
||||||
|
# Check for one-click unsubscribe support (RFC 8058)
|
||||||
|
# List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||||
|
if list_unsub_post and "List-Unsubscribe=One-Click" in list_unsub_post:
|
||||||
|
info.one_click = True
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_unsubscribe(
|
||||||
|
unsubscribe_info: UnsubscribeInfo,
|
||||||
|
prefer_http: bool = True,
|
||||||
|
) -> OperationResult:
|
||||||
|
"""
|
||||||
|
Execute an unsubscribe action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unsubscribe_info: The unsubscribe information from the email
|
||||||
|
prefer_http: If True, prefer HTTP unsubscribe over mailto
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OperationResult indicating success or failure
|
||||||
|
"""
|
||||||
|
if not unsubscribe_info.available:
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message="No unsubscribe option available for this email"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try HTTP unsubscribe first if preferred and available
|
||||||
|
if prefer_http and unsubscribe_info.http_url:
|
||||||
|
result = await _http_unsubscribe(
|
||||||
|
unsubscribe_info.http_url,
|
||||||
|
unsubscribe_info.one_click
|
||||||
|
)
|
||||||
|
if result.success:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Try mailto unsubscribe
|
||||||
|
if unsubscribe_info.mailto:
|
||||||
|
return _prepare_mailto_unsubscribe(unsubscribe_info.mailto)
|
||||||
|
|
||||||
|
# Fall back to HTTP if mailto didn't work
|
||||||
|
if unsubscribe_info.http_url and not prefer_http:
|
||||||
|
return await _http_unsubscribe(
|
||||||
|
unsubscribe_info.http_url,
|
||||||
|
unsubscribe_info.one_click
|
||||||
|
)
|
||||||
|
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message="Failed to unsubscribe using available methods"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_unsubscribe(url: str, one_click: bool) -> OperationResult:
|
||||||
|
"""
|
||||||
|
Perform HTTP-based unsubscribe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The unsubscribe URL
|
||||||
|
one_click: Whether RFC 8058 one-click is supported
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=30.0,
|
||||||
|
follow_redirects=True
|
||||||
|
) as client:
|
||||||
|
if one_click:
|
||||||
|
# RFC 8058: POST with List-Unsubscribe=One-Click
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
data={"List-Unsubscribe": "One-Click"},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Standard: GET request to the URL
|
||||||
|
response = await client.get(url)
|
||||||
|
|
||||||
|
if response.status_code in (200, 201, 202, 204):
|
||||||
|
return OperationResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Successfully unsubscribed via HTTP ({response.status_code})"
|
||||||
|
)
|
||||||
|
elif response.status_code in (301, 302, 303, 307, 308):
|
||||||
|
# Follow redirect manually if needed
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Unsubscribe requires manual confirmation at: {response.headers.get('Location', url)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message=f"HTTP unsubscribe failed with status {response.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message="Unsubscribe request timed out"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"HTTP unsubscribe error: {e}")
|
||||||
|
return OperationResult(
|
||||||
|
success=False,
|
||||||
|
message=f"HTTP unsubscribe failed: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_mailto_unsubscribe(mailto: str) -> OperationResult:
|
||||||
|
"""
|
||||||
|
Prepare mailto unsubscribe info.
|
||||||
|
|
||||||
|
Note: We can't send emails automatically without user consent,
|
||||||
|
so we return the mailto details for the user/agent to act on.
|
||||||
|
"""
|
||||||
|
# Parse mailto URL
|
||||||
|
# Format: mailto:unsub@example.com?subject=unsubscribe&body=...
|
||||||
|
if mailto.startswith("mailto:"):
|
||||||
|
mailto = mailto[7:]
|
||||||
|
|
||||||
|
parts = mailto.split("?", 1)
|
||||||
|
email = parts[0]
|
||||||
|
subject = ""
|
||||||
|
body = ""
|
||||||
|
|
||||||
|
if len(parts) > 1:
|
||||||
|
params = dict(p.split("=", 1) for p in parts[1].split("&") if "=" in p)
|
||||||
|
subject = params.get("subject", "unsubscribe")
|
||||||
|
body = params.get("body", "")
|
||||||
|
|
||||||
|
return OperationResult(
|
||||||
|
success=True,
|
||||||
|
message=f"To unsubscribe, send an email to: {email}",
|
||||||
|
id=email,
|
||||||
|
data={
|
||||||
|
"method": "mailto",
|
||||||
|
"email": email,
|
||||||
|
"subject": subject or "unsubscribe",
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -132,3 +132,42 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
|
|||||||
"""
|
"""
|
||||||
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)
|
||||||
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.")
|
||||||
|
def set_email_flags(
|
||||||
|
email_id: str,
|
||||||
|
mailbox: str,
|
||||||
|
add_flags: Optional[list[str]] = None,
|
||||||
|
remove_flags: Optional[list[str]] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Set or remove flags on an email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: The unique ID of the email
|
||||||
|
mailbox: The mailbox containing the email
|
||||||
|
add_flags: Flags to add (e.g., ["\\Flagged", "important"])
|
||||||
|
remove_flags: Flags to remove (e.g., ["\\Seen"])
|
||||||
|
"""
|
||||||
|
result = service.set_flags(email_id, mailbox, add_flags, remove_flags)
|
||||||
|
return result.model_dump()
|
||||||
|
|
||||||
|
@mcp.tool(description="Unsubscribe from a mailing list. Parses List-Unsubscribe headers and attempts automatic unsubscribe via HTTP or provides mailto instructions.")
|
||||||
|
async def unsubscribe_email(
|
||||||
|
email_id: str,
|
||||||
|
mailbox: str = "INBOX",
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Unsubscribe from a mailing list based on email headers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: The unique ID of the email containing unsubscribe info
|
||||||
|
mailbox: The mailbox containing the email (default: INBOX)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result with unsubscribe status. For HTTP unsubscribe, it attempts
|
||||||
|
automatic unsubscription. For mailto, it returns the email details
|
||||||
|
to send.
|
||||||
|
"""
|
||||||
|
result = await service.unsubscribe(mailbox, email_id)
|
||||||
|
return result.model_dump()
|
||||||
|
|||||||
Reference in New Issue
Block a user