Skip to main content
Glama
mail.py10.9 kB
""" IMAP client for the meeting scheduler. Uses .env for configuration and provides email operation functions. """ import email import email.message import imaplib import logging import os import ssl import time from ssl import SSLError from typing import Dict, List, Protocol, Tuple, Union from dotenv import load_dotenv # Load .env file (only relevant in production) load_dotenv() logger = logging.getLogger(__name__) class EmailClientProtocol(Protocol): """Protocol defining the interface for email client operations.""" def __enter__(self) -> "EmailClientProtocol": ... """Context manager entry.""" def __exit__(self, exc_type, exc_val, exc_tb) -> None: ... """Context manager exit.""" def connect(self) -> None: ... """Connect to the email server.""" def save_draft( self, subject: str, body: str, to: str, in_reply_to: str = "" ) -> bool: ... """Save an email as a draft.""" def search_emails( self, mailbox: str = "INBOX", criteria: str = "UNSEEN" ) -> List[int]: ... """Search emails in a mailbox.""" def get_email_content( self, email_id: int, mailbox: str = "INBOX" ) -> Tuple[str, str]: ... """Get email subject and body.""" def get_email_metadata( self, email_id: int, mailbox: str = "INBOX" ) -> Dict[str, str]: ... """Get email metadata including threading headers.""" def close(self) -> None: ... """Close the connection to the email server.""" class IMAPEmailClient: """Production implementation of EmailClientProtocol using IMAP.""" def __init__(self): self._imap: Union[imaplib.IMAP4, imaplib.IMAP4_SSL, None] = None def __enter__(self) -> "IMAPEmailClient": """Context manager entry.""" self.connect() return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: """Context manager exit.""" self.close() def connect(self) -> None: """Connect to the IMAP server.""" host = os.getenv("IMAP_HOST") user = os.getenv("IMAP_USER") password = os.getenv("IMAP_PASSWORD") port = os.getenv("IMAP_PORT", "993") use_ssl = os.getenv("IMAP_USE_SSL", "true") use_starttls = os.getenv("IMAP_USE_STARTTLS", "false") verify_ssl = os.getenv("IMAP_VERIFY_SSL", "true") # Handle None values and convert to boolean use_ssl = use_ssl.lower() == "true" if use_ssl else True use_starttls = use_starttls.lower() == "true" if use_starttls else False verify_ssl = verify_ssl.lower() == "true" if verify_ssl else True if host is None or user is None or password is None: raise ValueError("IMAP configuration in .env incomplete") try: # Convert port to integer port_int = int(port) # Create SSL context if needed ssl_context = None if use_ssl or use_starttls: ssl_context = ssl.create_default_context() if not verify_ssl: ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE # Connect using appropriate method if use_starttls: # Use IMAP4 with STARTTLS imap = imaplib.IMAP4(host, port_int) imap.starttls(ssl_context=ssl_context) else: # Use IMAP4_SSL (direct SSL/TLS) imap = imaplib.IMAP4_SSL(host, port_int, ssl_context=ssl_context) imap.login(user, password) self._imap = imap except imaplib.IMAP4.error as e: raise ConnectionError(f"IMAP connection failed: {e}") from e except ValueError as e: raise ValueError(f"Invalid IMAP port: {e}") from e except (OSError, SSLError) as e: raise ConnectionError(f"Network error: {e}") from e except Exception as e: raise ConnectionError(f"IMAP connection failed: {e}") from e def save_draft( self, subject: str, body: str, to: str, in_reply_to: str = "" ) -> bool: """Save an email as a draft using IMAP.""" if self._imap is None: raise ConnectionError("Not connected to email server") try: # Get drafts folder from environment variable drafts_folder = os.getenv("IMAP_DRAFT_FOLDER", "INBOX.Drafts") # Ensure the folder exists try: status, _ = self._imap.select(drafts_folder) if status != "OK": # Folder doesn't exist, try to create it status, _ = self._imap.create(drafts_folder) if status != "OK": logger.error( "Failed to create or access folder: %s", drafts_folder ) return False # Successfully created, select it self._imap.select(drafts_folder) except (ConnectionError, OSError) as e: logger.error("Error ensuring folder exists: %s", e) return False except Exception as e: logger.error("Unexpected error ensuring folder exists: %s", e) return False # Create the email message = email.message.EmailMessage() from_address = os.getenv("IMAP_FROM", os.getenv("IMAP_USER")) message["From"] = from_address message["To"] = to message["Subject"] = subject # Add email threading headers if provided if in_reply_to: message["In-Reply-To"] = in_reply_to # Generate References header for proper email threading message["References"] = in_reply_to message.set_content(body) # Save the email status, _ = self._imap.append( drafts_folder, "", imaplib.Time2Internaldate(time.time()), message.as_bytes(), ) return status == "OK" except (ConnectionError, OSError) as e: logger.error("Error saving draft: %s", e) return False except Exception as e: logger.error("Unexpected error saving draft: %s", e) return False def search_emails( self, mailbox: str = "INBOX", criteria: str = "UNSEEN" ) -> List[int]: """Search emails in a mailbox.""" if self._imap is None: raise ConnectionError("Not connected to email server") status, messages = self._imap.select(mailbox) if status != "OK": raise ConnectionError(f"Cannot select mailbox {mailbox}") status, email_ids = self._imap.search(None, criteria) if status != "OK": raise ConnectionError("Email search failed") return email_ids[0].split() def get_email_content( self, email_id: int, mailbox: str = "INBOX" ) -> Tuple[str, str]: """Get email subject and body.""" if self._imap is None: raise ConnectionError("Not connected to email server") status, data = self._imap.fetch(str(email_id), "(RFC822)") if status != "OK": raise ConnectionError(f"Cannot retrieve email {email_id}") # Type safety: Ensure data has the expected structure if not data or not data[0] or len(data[0]) < 2: raise ConnectionError(f"Invalid email data structure for email {email_id}") raw_email = data[0][1] if not isinstance(raw_email, bytes): raise ConnectionError( f"Expected bytes for email data, got {type(raw_email)}" ) email_message = email.message_from_bytes(raw_email) subject = email_message.get("subject", "") body = "" if email_message.is_multipart(): for part in email_message.walk(): content_type = part.get_content_type() if content_type == "text/plain": payload = part.get_payload(decode=True) body = ( payload.decode() if isinstance(payload, bytes) else str(payload) ) break else: payload = email_message.get_payload(decode=True) body = payload.decode() if isinstance(payload, bytes) else str(payload) return subject, body def get_email_metadata( self, email_id: int, mailbox: str = "INBOX" ) -> Dict[str, str]: """Get email metadata including threading headers.""" if self._imap is None: raise ConnectionError("Not connected to email server") status, data = self._imap.fetch(str(email_id), "(RFC822)") if status != "OK": raise ConnectionError(f"Cannot retrieve email {email_id}") # Type safety: Ensure data has the expected structure if not data or not data[0] or len(data[0]) < 2: raise ConnectionError(f"Invalid email data structure for email {email_id}") raw_email = data[0][1] if not isinstance(raw_email, bytes): raise ConnectionError( f"Expected bytes for email data, got {type(raw_email)}" ) email_message = email.message_from_bytes(raw_email) # Extract metadata metadata = { "subject": email_message.get("subject", ""), "from": email_message.get("from", ""), "to": email_message.get("to", ""), "date": email_message.get("date", ""), "message_id": email_message.get("Message-ID", ""), "in_reply_to": email_message.get("In-Reply-To", ""), "references": email_message.get("References", ""), } # Extract body content body = "" if email_message.is_multipart(): for part in email_message.walk(): content_type = part.get_content_type() if content_type == "text/plain": payload = part.get_payload(decode=True) body = ( payload.decode() if isinstance(payload, bytes) else str(payload) ) break else: payload = email_message.get_payload(decode=True) body = payload.decode() if isinstance(payload, bytes) else str(payload) metadata["body"] = body return metadata def close(self) -> None: """Close the IMAP connection.""" if self._imap is not None: try: self._imap.close() self._imap.logout() except (ConnectionError, OSError): pass self._imap = None

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/seb-schulz/meeting-scheduler-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server