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.
438 lines
15 KiB
Python
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)
|