feat(01-02): implement IMAP connection service with authentication and folder listing
- Created IMAPService class with async connect/list_folders/close methods - Uses aioimaplib for async IMAP4_SSL operations - Implements proper connection cleanup with logout() not close() - Added aioimaplib and html2text dependencies to pyproject.toml - Follows async patterns from existing service classes (ynab_service.py, mealie_service.py) - Includes comprehensive logging with [IMAP] and [IMAP ERROR] prefixes
This commit is contained in:
142
blueprints/email/imap_service.py
Normal file
142
blueprints/email/imap_service.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""IMAP connection service for email operations.
|
||||
|
||||
Provides async IMAP client for connecting to mail servers, listing folders,
|
||||
and fetching messages. Uses aioimaplib for async IMAP4 operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aioimaplib import IMAP4_SSL
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IMAPService:
|
||||
"""Async IMAP client for email operations."""
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
port: int = 993,
|
||||
timeout: int = 10,
|
||||
) -> IMAP4_SSL:
|
||||
"""
|
||||
Establish IMAP connection with authentication.
|
||||
|
||||
Args:
|
||||
host: IMAP server hostname (e.g., imap.gmail.com)
|
||||
username: IMAP username (usually email address)
|
||||
password: IMAP password or app-specific password
|
||||
port: IMAP port (default 993 for SSL)
|
||||
timeout: Connection timeout in seconds (default 10)
|
||||
|
||||
Returns:
|
||||
Authenticated IMAP4_SSL client ready for operations
|
||||
|
||||
Raises:
|
||||
Exception: On connection or authentication failure
|
||||
|
||||
Note:
|
||||
Caller must call close() to properly disconnect when done.
|
||||
"""
|
||||
logger.info(f"[IMAP] Connecting to {host}:{port} as {username}")
|
||||
|
||||
try:
|
||||
# Create connection with timeout
|
||||
imap = IMAP4_SSL(host=host, port=port, timeout=timeout)
|
||||
|
||||
# Wait for server greeting
|
||||
await imap.wait_hello_from_server()
|
||||
logger.info(f"[IMAP] Server greeting received from {host}")
|
||||
|
||||
# Authenticate
|
||||
login_response = await imap.login(username, password)
|
||||
logger.info(f"[IMAP] Authentication successful: {login_response}")
|
||||
|
||||
return imap
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[IMAP ERROR] Connection failed to {host}: {type(e).__name__}: {str(e)}"
|
||||
)
|
||||
# Best effort cleanup
|
||||
try:
|
||||
if "imap" in locals():
|
||||
await imap.logout()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
async def list_folders(self, imap: IMAP4_SSL) -> list[str]:
|
||||
"""
|
||||
List all mailbox folders.
|
||||
|
||||
Args:
|
||||
imap: Authenticated IMAP4_SSL client
|
||||
|
||||
Returns:
|
||||
List of folder names (e.g., ["INBOX", "Sent", "Drafts"])
|
||||
|
||||
Note:
|
||||
Parses IMAP LIST response format: (* LIST (...) "/" "INBOX")
|
||||
"""
|
||||
logger.info("[IMAP] Listing mailbox folders")
|
||||
|
||||
try:
|
||||
# LIST command: list('""', '*') lists all folders
|
||||
response = await imap.list('""', "*")
|
||||
logger.info(f"[IMAP] LIST response status: {response}")
|
||||
|
||||
folders = []
|
||||
|
||||
# Parse LIST response lines
|
||||
# Format: * LIST (\HasNoChildren) "/" "INBOX"
|
||||
# Or: * LIST (\HasChildren \Noselect) "/" "folder name"
|
||||
for line in response.lines:
|
||||
# Decode bytes to string if needed
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8", errors="ignore")
|
||||
|
||||
# Extract folder name from response
|
||||
# Match pattern: "folder name" at end of line
|
||||
match = re.search(r'"([^"]+)"\s*$', line)
|
||||
if match:
|
||||
folder_name = match.group(1)
|
||||
folders.append(folder_name)
|
||||
logger.debug(f"[IMAP] Found folder: {folder_name}")
|
||||
|
||||
logger.info(f"[IMAP] Found {len(folders)} folders")
|
||||
return folders
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[IMAP ERROR] Failed to list folders: {type(e).__name__}: {str(e)}"
|
||||
)
|
||||
raise
|
||||
|
||||
async def close(self, imap: IMAP4_SSL) -> None:
|
||||
"""
|
||||
Properly close IMAP connection.
|
||||
|
||||
Args:
|
||||
imap: IMAP4_SSL client to close
|
||||
|
||||
Note:
|
||||
CRITICAL: Must use logout(), not close().
|
||||
close() only closes the selected mailbox, logout() closes TCP connection.
|
||||
"""
|
||||
logger.info("[IMAP] Closing connection")
|
||||
|
||||
try:
|
||||
# Use logout() to close TCP connection
|
||||
await imap.logout()
|
||||
logger.info("[IMAP] Connection closed successfully")
|
||||
except Exception as e:
|
||||
# Best effort cleanup - don't fail on close
|
||||
logger.warning(
|
||||
f"[IMAP] Error during logout (non-fatal): {type(e).__name__}: {str(e)}"
|
||||
)
|
||||
Reference in New Issue
Block a user