"""Configuration system for Mail email reader using Pydantic.""" import logging import os from pathlib import Path from typing import Literal, Optional import toml from pydantic import BaseModel, Field logger = logging.getLogger(__name__) class TaskBackendConfig(BaseModel): """Configuration for task management backend.""" backend: Literal["taskwarrior", "dstask"] = "taskwarrior" taskwarrior_path: str = "task" dstask_path: str = Field( default_factory=lambda: str(Path.home() / ".local" / "bin" / "dstask") ) class EnvelopeDisplayConfig(BaseModel): """Configuration for envelope list item rendering.""" # Sender display max_sender_length: int = 25 # Date/time display date_format: str = "%m/%d" time_format: str = "%H:%M" show_date: bool = True show_time: bool = True # Grouping group_by: Literal["relative", "absolute"] = "relative" # relative: "Today", "Yesterday", "This Week", etc. # absolute: "December 2025", "November 2025", etc. # Layout lines: Literal[2, 3] = 2 # 2: sender/date on line 1, subject on line 2 # 3: sender/date on line 1, subject on line 2, preview on line 3 show_checkbox: bool = True show_preview: bool = False # Only used when lines=3 # NerdFont icons for status icon_unread: str = "\uf0e0" # nf-fa-envelope (filled) icon_read: str = "\uf2b6" # nf-fa-envelope_open (open) icon_flagged: str = "\uf024" # nf-fa-flag icon_attachment: str = "\uf0c6" # nf-fa-paperclip class KeybindingsConfig(BaseModel): """Keybinding customization.""" next_message: str = "j" prev_message: str = "k" delete: str = "#" archive: str = "e" open_by_id: str = "o" quit: str = "q" toggle_header: str = "h" create_task: str = "t" reload: str = "%" toggle_sort: str = "s" toggle_selection: str = "space" clear_selection: str = "escape" scroll_page_down: str = "pagedown" scroll_page_down: str = "space" scroll_page_up: str = "b" toggle_main_content: str = "w" open_links: str = "l" toggle_view_mode: str = "m" class ContentDisplayConfig(BaseModel): """Configuration for message content display.""" # View mode: "markdown" for pretty rendering, "html" for raw/plain display default_view_mode: Literal["markdown", "html"] = "markdown" class LinkPanelConfig(BaseModel): """Configuration for the link panel.""" # Whether to close the panel after opening a link close_on_open: bool = False class MailOperationsConfig(BaseModel): """Configuration for mail operations.""" # Folder to move messages to when archiving archive_folder: str = "Archive" class ThemeConfig(BaseModel): """Theme/appearance settings.""" theme_name: str = "monokai" class MailAppConfig(BaseModel): """Main configuration for Mail email reader.""" task: TaskBackendConfig = Field(default_factory=TaskBackendConfig) envelope_display: EnvelopeDisplayConfig = Field( default_factory=EnvelopeDisplayConfig ) content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig) link_panel: LinkPanelConfig = Field(default_factory=LinkPanelConfig) mail: MailOperationsConfig = Field(default_factory=MailOperationsConfig) keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig) theme: ThemeConfig = Field(default_factory=ThemeConfig) @classmethod def get_config_path(cls) -> Path: """Get the path to the config file.""" # Check environment variable first env_path = os.getenv("LUK_MAIL_CONFIG") if env_path: return Path(env_path) # Default to ~/.config/luk/mail.toml return Path.home() / ".config" / "luk" / "mail.toml" @classmethod def load(cls, config_path: Optional[Path] = None) -> "MailAppConfig": """Load config from TOML file with defaults for missing values.""" if config_path is None: config_path = cls.get_config_path() if config_path.exists(): try: with open(config_path, "r") as f: data = toml.load(f) logger.info(f"Loaded config from {config_path}") return cls.model_validate(data) except Exception as e: logger.warning(f"Error loading config from {config_path}: {e}") logger.warning("Using default configuration") return cls() else: logger.info(f"No config file at {config_path}, using defaults") return cls() def save(self, config_path: Optional[Path] = None) -> None: """Save current config to TOML file.""" if config_path is None: config_path = self.get_config_path() # Ensure parent directory exists config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, "w") as f: toml.dump(self.model_dump(), f) logger.info(f"Saved config to {config_path}") # Global config instance (lazy-loaded) _config: Optional[MailAppConfig] = None def get_config() -> MailAppConfig: """Get the global config instance, loading it if necessary.""" global _config if _config is None: _config = MailAppConfig.load() return _config def reload_config() -> MailAppConfig: """Force reload of the config from disk.""" global _config _config = MailAppConfig.load() return _config