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.
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user