Files
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

438 lines
15 KiB
Python

"""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)