"""Alert Sender module for the PAT Manager system. Sends alerts via OrgMyLife task creation or email when PATs require manual renewal. Supports idempotent alerting by checking for existing open tasks before creating duplicates. """ from __future__ import annotations import asyncio import logging import os import smtplib from email.message import EmailMessage import httpx from .errors import AlertError from .models import AlertChannel, AlertResult, CheckResult logger = logging.getLogger(__name__) class AlertSender: """Sends alert notifications for PATs requiring manual renewal. Supports two channels: - OrgMyLife: Creates a task via the OrgMyLife API - Email: Sends an email via SMTP Attributes: channel: The alert delivery channel (OrgMyLife or email). config: Configuration dict containing API keys, email settings, etc. """ def __init__(self, channel: AlertChannel, config: dict) -> None: """Initialize the AlertSender. Args: channel: The alert channel to use (OrgMyLife or email). config: Configuration dictionary. Expected keys depend on channel: OrgMyLife: - orgmylife_url: Base URL (default: https://orgmylife.app) - orgmylife_api_key: API key for X-API-Key header - orgmylife_user: Basic auth username (alternative) - orgmylife_pass: Basic auth password (alternative) Email: - smtp_host: SMTP server hostname - smtp_port: SMTP server port - smtp_user: SMTP username - smtp_pass: SMTP password - alert_email_to: Recipient email address """ self.channel = channel self.config = config async def send_alert(self, result: CheckResult) -> AlertResult: """Send an alert for a PAT that requires manual renewal. Creates an OrgMyLife task or sends an email based on the configured channel. Implements retry logic (1 retry with 2s backoff) on failure. Args: result: The CheckResult for the PAT requiring an alert. Returns: AlertResult indicating whether the alert was sent successfully. Raises: AlertError: If the alert cannot be delivered after retries. """ if self.channel == AlertChannel.ORGMYLIFE: return await self._send_orgmylife_alert(result) elif self.channel == AlertChannel.EMAIL: return await self._send_email_alert(result) else: raise AlertError( channel=self.channel.value, reason=f"Unsupported alert channel: {self.channel.value}", ) async def check_existing_task( self, service: str, token_name: str, expiry_date: str ) -> bool: """Check if an open OrgMyLife task already exists for this PAT. Queries the OrgMyLife API for open tasks and checks if one matches the expected title pattern for this PAT and expiry date. Args: service: The service name (e.g. "Jira"). token_name: The token identifier. expiry_date: The expiry date string (ISO 8601). Returns: True if a matching open task already exists, False otherwise. """ base_url = self.config.get( "orgmylife_url", os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"), ) url = f"{base_url}/api/tasks" headers = self._get_orgmylife_headers() expected_title = f"[PAT Renewal] {service} - {token_name}" try: async with httpx.AsyncClient(timeout=30) as client: response = await client.get(url, headers=headers) if not (200 <= response.status_code < 300): # Cannot verify existing tasks — proceed with creation logger.warning( "Could not check existing tasks (HTTP %d), proceeding with creation", response.status_code, ) return False tasks = response.json() # Search for an open task with matching title if isinstance(tasks, list): for task in tasks: title = task.get("title", "") if title == expected_title: return True elif isinstance(tasks, dict): # Handle paginated or wrapped response task_list = tasks.get("tasks", tasks.get("items", [])) for task in task_list: title = task.get("title", "") if title == expected_title: return True except (httpx.TimeoutException, httpx.ConnectError) as exc: logger.warning( "Could not check existing tasks (%s), proceeding with creation", exc, ) return False return False def _get_orgmylife_headers(self) -> dict[str, str]: """Build authentication headers for OrgMyLife API. Prefers API key authentication. Falls back to basic auth if API key is not available. Returns: Dictionary of HTTP headers for authentication. """ api_key = self.config.get( "orgmylife_api_key", os.environ.get("ORGMYLIFE_API_KEY", ""), ) if api_key: return { "X-API-Key": api_key, "Content-Type": "application/json", } # Fall back to basic auth user = self.config.get( "orgmylife_user", os.environ.get("ORGMYLIFE_USER", ""), ) password = self.config.get( "orgmylife_pass", os.environ.get("ORGMYLIFE_PASS", ""), ) if user and password: import base64 credentials = base64.b64encode(f"{user}:{password}".encode()).decode() return { "Authorization": f"Basic {credentials}", "Content-Type": "application/json", } return {"Content-Type": "application/json"} def _build_task_title(self, result: CheckResult) -> str: """Build the OrgMyLife task title for a PAT alert. Args: result: The CheckResult for the PAT. Returns: Task title in format: [PAT Renewal] {service} - {token_name} """ return f"[PAT Renewal] {result.entry.service} - {result.entry.token_name}" def _build_task_description(self, result: CheckResult) -> str: """Build the OrgMyLife task description for a PAT alert. Args: result: The CheckResult for the PAT. Returns: Task description with days remaining and renewal instructions. """ service = result.entry.service token_name = result.entry.token_name expiry_date = result.entry.expiry_date or "unknown" days_remaining = result.days_remaining if result.days_remaining is not None else 0 if days_remaining == 0: return ( f"Token has expired (0 days remaining). Manual renewal required.\n\n" f"Service: {service}\n" f"Token: {token_name}\n" f"Expiry date: {expiry_date}" ) else: return ( f"Token expires in {days_remaining} days ({expiry_date}). " f"Manual renewal required.\n\n" f"Service: {service}\n" f"Token: {token_name}\n" f"Expiry date: {expiry_date}" ) async def _send_orgmylife_alert(self, result: CheckResult) -> AlertResult: """Send an alert by creating an OrgMyLife task. Checks for existing tasks first (idempotent alerting), then creates a new task if none exists. Retries once with 2s backoff on failure. Args: result: The CheckResult for the PAT. Returns: AlertResult indicating success or failure. Raises: AlertError: If task creation fails after retry. """ service = result.entry.service token_name = result.entry.token_name expiry_date = result.entry.expiry_date or "" # Check for existing task (idempotent alerting) if expiry_date: existing = await self.check_existing_task(service, token_name, expiry_date) if existing: logger.info( "Open task already exists for %s/%s, skipping creation", service, token_name, ) return AlertResult( sent=False, channel=AlertChannel.ORGMYLIFE, error_message=None, ) base_url = self.config.get( "orgmylife_url", os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"), ) url = f"{base_url}/api/tasks" headers = self._get_orgmylife_headers() title = self._build_task_title(result) description = self._build_task_description(result) payload = {"title": title, "description": description} # Attempt with 1 retry and 2s backoff last_error: str | None = None for attempt in range(2): try: async with httpx.AsyncClient(timeout=30) as client: response = await client.post(url, headers=headers, json=payload) if 200 <= response.status_code < 300: logger.info( "Created OrgMyLife task for %s/%s: %s", service, token_name, title, ) return AlertResult( sent=True, channel=AlertChannel.ORGMYLIFE, error_message=None, ) else: last_error = ( f"OrgMyLife API returned HTTP {response.status_code}" ) logger.warning( "OrgMyLife task creation failed (attempt %d): HTTP %d", attempt + 1, response.status_code, ) except (httpx.TimeoutException, httpx.ConnectError) as exc: last_error = f"OrgMyLife API unreachable: {exc}" logger.warning( "OrgMyLife task creation failed (attempt %d): %s", attempt + 1, exc, ) # Retry after 2s backoff (only on first attempt) if attempt == 0: await asyncio.sleep(2) # All attempts failed error_msg = last_error or "Unknown error creating OrgMyLife task" logger.error("Alert delivery failed via orgmylife: %s", error_msg) raise AlertError(channel="orgmylife", reason=error_msg) async def _send_email_alert(self, result: CheckResult) -> AlertResult: """Send an alert via email using SMTP. Retries once with 2s backoff on failure. Args: result: The CheckResult for the PAT. Returns: AlertResult indicating success or failure. Raises: AlertError: If email sending fails after retry. """ smtp_host = self.config.get( "smtp_host", os.environ.get("SMTP_HOST", "") ) smtp_port = int( self.config.get("smtp_port", os.environ.get("SMTP_PORT", "587")) ) smtp_user = self.config.get( "smtp_user", os.environ.get("SMTP_USER", "") ) smtp_pass = self.config.get( "smtp_pass", os.environ.get("SMTP_PASS", "") ) email_to = self.config.get( "alert_email_to", os.environ.get("ALERT_EMAIL_TO", "") ) if not smtp_host or not email_to: error_msg = "SMTP configuration incomplete (missing smtp_host or alert_email_to)" logger.error("Alert delivery failed via email: %s", error_msg) raise AlertError(channel="email", reason=error_msg) service = result.entry.service token_name = result.entry.token_name expiry_date = result.entry.expiry_date or "unknown" days_remaining = result.days_remaining if result.days_remaining is not None else 0 subject = f"[PAT Renewal] {service} - {token_name}" if days_remaining == 0: body = ( f"Token has expired (0 days remaining). Manual renewal required.\n\n" f"Service: {service}\n" f"Token: {token_name}\n" f"Expiry date: {expiry_date}\n\n" f"Please renew this token as soon as possible." ) else: body = ( f"Token expires in {days_remaining} days ({expiry_date}). " f"Manual renewal required.\n\n" f"Service: {service}\n" f"Token: {token_name}\n" f"Expiry date: {expiry_date}\n\n" f"Please renew this token before it expires." ) msg = EmailMessage() msg["Subject"] = subject msg["From"] = smtp_user or "pat-manager@noreply.local" msg["To"] = email_to msg.set_content(body) # Attempt with 1 retry and 2s backoff last_error: str | None = None for attempt in range(2): try: await asyncio.to_thread( self._send_smtp_message, smtp_host, smtp_port, smtp_user, smtp_pass, msg ) logger.info( "Sent email alert for %s/%s to %s", service, token_name, email_to, ) return AlertResult( sent=True, channel=AlertChannel.EMAIL, error_message=None, ) except Exception as exc: last_error = f"Email sending failed: {exc}" logger.warning( "Email alert failed (attempt %d): %s", attempt + 1, exc, ) # Retry after 2s backoff (only on first attempt) if attempt == 0: await asyncio.sleep(2) # All attempts failed error_msg = last_error or "Unknown error sending email" logger.error("Alert delivery failed via email: %s", error_msg) raise AlertError(channel="email", reason=error_msg) @staticmethod def _send_smtp_message( host: str, port: int, user: str, password: str, msg: EmailMessage ) -> None: """Send an email message via SMTP (blocking, run in thread). Args: host: SMTP server hostname. port: SMTP server port. user: SMTP username for authentication. password: SMTP password for authentication. msg: The email message to send. """ with smtplib.SMTP(host, port, timeout=30) as server: server.ehlo() if port != 25: server.starttls() server.ehlo() if user and password: server.login(user, password) server.send_message(msg)