Add email delivery for generated reports

This commit is contained in:
2026-05-20 15:04:17 +02:00
parent 152e7fd181
commit a19039a4fb
11 changed files with 454 additions and 3 deletions
+11
View File
@@ -24,6 +24,7 @@ from pve_backup_report.coverage import (
STATUS_PBS_PLANNED,
analyze_backup_coverage,
)
from pve_backup_report.email_report import EmailReportError, send_report_email
from pve_backup_report.logging_config import configure_logging
from pve_backup_report.pbs_client import PbsApiError, PbsClient
from pve_backup_report.pve_client import PveApiError, PveClient
@@ -212,6 +213,16 @@ def run(argv: Sequence[str] | None = None) -> int:
return 4
logger.info("Rapport PDF genere: %s", pdf_path)
if config.email.enabled:
try:
send_report_email(config.email, pdf_path)
except EmailReportError as exc:
logger.error("Envoi email echoue: %s", exc)
return 6
logger.info(
"Rapport PDF envoye par email a %s",
", ".join(config.email.smtp_to),
)
return 0 if not any(issue.severity == "error" for issue in report_data.issues) else 5
if args.debug_last_backup_vmid is not None:
+97 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from pve_backup_report.i18n import TranslationError, normalize_language
@@ -33,6 +33,21 @@ class PbsServerConfig:
)
@dataclass(frozen=True)
class EmailConfig:
enabled: bool = False
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
smtp_from: str | None = None
smtp_to: tuple[str, ...] = ()
smtp_starttls: bool = True
smtp_ssl: bool = False
smtp_timeout_seconds: int = 30
subject: str = "Rapport sauvegardes PVE"
@dataclass(frozen=True)
class AppConfig:
pve_api_url: str
@@ -51,6 +66,7 @@ class AppConfig:
log_level: str
report_filename_prefix: str
report_language: str = "fr"
email: EmailConfig = field(default_factory=EmailConfig)
@property
def configured_pbs_servers(self) -> tuple[PbsServerConfig, ...]:
@@ -103,10 +119,80 @@ def load_config(env_file: str | Path | None = ".env") -> AppConfig:
"REPORT_FILENAME_PREFIX",
"rapport-sauvegardes-pve",
),
email=parse_email_config(os.environ),
)
return config
def parse_email_config(environ: Mapping[str, str]) -> EmailConfig:
enabled = parse_bool(
environ.get("REPORT_EMAIL_ENABLED", "false"),
"REPORT_EMAIL_ENABLED",
)
smtp_ssl = parse_bool(
environ.get("REPORT_EMAIL_SMTP_SSL", "false"),
"REPORT_EMAIL_SMTP_SSL",
)
smtp_starttls = parse_bool(
environ.get("REPORT_EMAIL_SMTP_STARTTLS", "true"),
"REPORT_EMAIL_SMTP_STARTTLS",
)
if smtp_ssl and smtp_starttls:
raise ConfigError(
"REPORT_EMAIL_SMTP_SSL et REPORT_EMAIL_SMTP_STARTTLS ne peuvent pas etre actifs ensemble"
)
smtp_username = parse_optional_string(environ.get("REPORT_EMAIL_SMTP_USERNAME"))
smtp_password = parse_optional_string(environ.get("REPORT_EMAIL_SMTP_PASSWORD"))
validate_optional_group(
"REPORT_EMAIL_SMTP_AUTH",
{
"REPORT_EMAIL_SMTP_USERNAME": smtp_username,
"REPORT_EMAIL_SMTP_PASSWORD": smtp_password,
},
)
smtp_to = parse_string_list(environ.get("REPORT_EMAIL_TO", ""), "REPORT_EMAIL_TO")
config = EmailConfig(
enabled=enabled,
smtp_host=parse_optional_string(environ.get("REPORT_EMAIL_SMTP_HOST")),
smtp_port=parse_int(
environ.get("REPORT_EMAIL_SMTP_PORT", "587"),
"REPORT_EMAIL_SMTP_PORT",
),
smtp_username=smtp_username,
smtp_password=smtp_password,
smtp_from=parse_optional_string(environ.get("REPORT_EMAIL_FROM")),
smtp_to=smtp_to,
smtp_starttls=smtp_starttls,
smtp_ssl=smtp_ssl,
smtp_timeout_seconds=parse_int(
environ.get("REPORT_EMAIL_SMTP_TIMEOUT_SECONDS", "30"),
"REPORT_EMAIL_SMTP_TIMEOUT_SECONDS",
),
subject=(
parse_optional_string(environ.get("REPORT_EMAIL_SUBJECT"))
or "Rapport sauvegardes PVE"
),
)
if enabled:
missing = []
if config.smtp_host is None:
missing.append("REPORT_EMAIL_SMTP_HOST")
if config.smtp_from is None:
missing.append("REPORT_EMAIL_FROM")
if not config.smtp_to:
missing.append("REPORT_EMAIL_TO")
if missing:
raise ConfigError(
"configuration email incomplete: variables manquantes "
+ ", ".join(missing)
)
return config
PBS_SERVER_ENV_PATTERN = re.compile(r"^PBS(\d+)_([A-Z0-9_]+)$")
PBS_SERVER_REQUIRED_KEYS = ("API_URL", "API_TOKEN_ID", "API_TOKEN_SECRET")
PBS_SERVER_KNOWN_KEYS = {
@@ -272,3 +358,13 @@ def parse_mapping(value: str, name: str) -> dict[str, str]:
raise ConfigError(f"{name} contient une entree vide")
mapping[key] = mapped_value
return mapping
def parse_string_list(value: str | None, name: str) -> tuple[str, ...]:
if value is None or value.strip() == "":
return ()
items = tuple(item.strip() for item in value.split(",") if item.strip())
if not items:
raise ConfigError(f"{name} contient une entree vide")
return items
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from pathlib import Path
from pve_backup_report.config import EmailConfig
from pve_backup_report.sanitization import sanitize_message
class EmailReportError(RuntimeError):
"""Erreur d'envoi du rapport par email."""
def send_report_email(config: EmailConfig, pdf_path: Path) -> None:
if not config.enabled:
return
if config.smtp_host is None or config.smtp_from is None or not config.smtp_to:
raise EmailReportError("configuration email incomplete")
message = build_report_message(config, pdf_path)
try:
if config.smtp_ssl:
with smtplib.SMTP_SSL(
config.smtp_host,
config.smtp_port,
timeout=config.smtp_timeout_seconds,
) as smtp:
authenticate_and_send(smtp, config, message)
return
with smtplib.SMTP(
config.smtp_host,
config.smtp_port,
timeout=config.smtp_timeout_seconds,
) as smtp:
if config.smtp_starttls:
smtp.starttls()
authenticate_and_send(smtp, config, message)
except (OSError, smtplib.SMTPException) as exc:
raise EmailReportError(sanitize_message(exc)) from exc
def build_report_message(config: EmailConfig, pdf_path: Path) -> EmailMessage:
message = EmailMessage()
message["Subject"] = config.subject
message["From"] = config.smtp_from or ""
message["To"] = ", ".join(config.smtp_to)
message.set_content(
"Bonjour,\n\n"
"Veuillez trouver ci-joint le rapport de sauvegardes Proxmox VE.\n\n"
"Cordialement.\n"
)
message.add_attachment(
pdf_path.read_bytes(),
maintype="application",
subtype="pdf",
filename=pdf_path.name,
)
return message
def authenticate_and_send(
smtp: smtplib.SMTP | smtplib.SMTP_SSL,
config: EmailConfig,
message: EmailMessage,
) -> None:
if config.smtp_username is not None and config.smtp_password is not None:
smtp.login(config.smtp_username, config.smtp_password)
smtp.send_message(message)