Add email delivery for generated reports
This commit is contained in:
@@ -33,3 +33,16 @@ PBS01_CA_BUNDLE=
|
|||||||
PBS01_TIMEOUT_SECONDS=30
|
PBS01_TIMEOUT_SECONDS=30
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
||||||
|
# Envoi optionnel du PDF genere par email.
|
||||||
|
REPORT_EMAIL_ENABLED=false
|
||||||
|
REPORT_EMAIL_SMTP_HOST=
|
||||||
|
REPORT_EMAIL_SMTP_PORT=587
|
||||||
|
REPORT_EMAIL_SMTP_STARTTLS=true
|
||||||
|
REPORT_EMAIL_SMTP_SSL=false
|
||||||
|
REPORT_EMAIL_SMTP_USERNAME=
|
||||||
|
REPORT_EMAIL_SMTP_PASSWORD=
|
||||||
|
REPORT_EMAIL_FROM=
|
||||||
|
# Destinataires separes par des virgules.
|
||||||
|
REPORT_EMAIL_TO=
|
||||||
|
REPORT_EMAIL_SUBJECT=Rapport sauvegardes PVE
|
||||||
|
REPORT_EMAIL_SMTP_TIMEOUT_SECONDS=30
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ PVE_TASK_HISTORY_LIMIT=500
|
|||||||
PBS_HOSTNAMES=backup.example.invalid=display-name
|
PBS_HOSTNAMES=backup.example.invalid=display-name
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
||||||
|
REPORT_EMAIL_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
## Variable Description
|
## Variable Description
|
||||||
@@ -56,6 +57,46 @@ REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
|||||||
| `PBS<number>_TIMEOUT_SECONDS` | No | HTTP timeout for this PBS server. Default: `PVE_TIMEOUT_SECONDS` or `30`. |
|
| `PBS<number>_TIMEOUT_SECONDS` | No | HTTP timeout for this PBS server. Default: `PVE_TIMEOUT_SECONDS` or `30`. |
|
||||||
| `LOG_LEVEL` | No | Log level. Default: `INFO`. |
|
| `LOG_LEVEL` | No | Log level. Default: `INFO`. |
|
||||||
| `REPORT_FILENAME_PREFIX` | No | PDF filename prefix. |
|
| `REPORT_FILENAME_PREFIX` | No | PDF filename prefix. |
|
||||||
|
| `REPORT_EMAIL_ENABLED` | No | Enables sending the generated PDF by email. Default: `false`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_HOST` | If email enabled | SMTP server. |
|
||||||
|
| `REPORT_EMAIL_SMTP_PORT` | No | SMTP port. Default: `587`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_STARTTLS` | No | Enables STARTTLS. Default: `true`. Incompatible with `REPORT_EMAIL_SMTP_SSL=true`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_SSL` | No | Uses implicit TLS SMTP, often port `465`. Default: `false`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_USERNAME` | No | SMTP username. If set, `REPORT_EMAIL_SMTP_PASSWORD` is required. |
|
||||||
|
| `REPORT_EMAIL_SMTP_PASSWORD` | No | SMTP password, read only from the environment and never displayed. |
|
||||||
|
| `REPORT_EMAIL_FROM` | If email enabled | Message sender address. |
|
||||||
|
| `REPORT_EMAIL_TO` | If email enabled | Comma-separated recipients. |
|
||||||
|
| `REPORT_EMAIL_SUBJECT` | No | Message subject. Default: `Rapport sauvegardes PVE`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_TIMEOUT_SECONDS` | No | SMTP timeout. Default: `30`. |
|
||||||
|
|
||||||
|
## Email Delivery
|
||||||
|
|
||||||
|
Email delivery is optional and disabled by default. When `REPORT_EMAIL_ENABLED=true`, the `--generate-pdf` command first writes the timestamped PDF to `REPORT_OUTPUT_DIR`, then sends it as an attachment. If sending fails, the PDF remains on disk and the command returns an error so cron or monitoring can report it.
|
||||||
|
|
||||||
|
Example with STARTTLS:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REPORT_EMAIL_ENABLED=true
|
||||||
|
REPORT_EMAIL_SMTP_HOST=smtp.example.invalid
|
||||||
|
REPORT_EMAIL_SMTP_PORT=587
|
||||||
|
REPORT_EMAIL_SMTP_STARTTLS=true
|
||||||
|
REPORT_EMAIL_SMTP_SSL=false
|
||||||
|
REPORT_EMAIL_SMTP_USERNAME=backup-report@example.invalid
|
||||||
|
REPORT_EMAIL_SMTP_PASSWORD=
|
||||||
|
REPORT_EMAIL_FROM=backup-report@example.invalid
|
||||||
|
REPORT_EMAIL_TO=admin@example.invalid,audit@example.invalid
|
||||||
|
REPORT_EMAIL_SUBJECT=PVE backup report
|
||||||
|
```
|
||||||
|
|
||||||
|
Example with implicit TLS:
|
||||||
|
|
||||||
|
```env
|
||||||
|
REPORT_EMAIL_SMTP_PORT=465
|
||||||
|
REPORT_EMAIL_SMTP_STARTTLS=false
|
||||||
|
REPORT_EMAIL_SMTP_SSL=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not put SMTP passwords in the Docker image, logs or committed documentation. Use `.env`, a runtime environment variable or a Docker secret mounted at startup.
|
||||||
|
|
||||||
## Docker Compose Example
|
## Docker Compose Example
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ docker compose run --rm pve-backup-report --generate-pdf
|
|||||||
```
|
```
|
||||||
|
|
||||||
The report is created in `REPORT_OUTPUT_DIR`. The filename contains a timestamp and never overwrites previous reports.
|
The report is created in `REPORT_OUTPUT_DIR`. The filename contains a timestamp and never overwrites previous reports.
|
||||||
|
If `REPORT_EMAIL_ENABLED=true`, the generated PDF is then sent to the `REPORT_EMAIL_TO` recipients.
|
||||||
|
|
||||||
Example file generated on the host:
|
Example file generated on the host:
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ PVE_TASK_HISTORY_LIMIT=500
|
|||||||
PBS_HOSTNAMES=backup.example.invalid=nom-affiche
|
PBS_HOSTNAMES=backup.example.invalid=nom-affiche
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
||||||
|
REPORT_EMAIL_ENABLED=false
|
||||||
```
|
```
|
||||||
|
|
||||||
## Description des variables
|
## Description des variables
|
||||||
@@ -56,6 +57,46 @@ REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve
|
|||||||
| `PBS<number>_TIMEOUT_SECONDS` | Non | Timeout HTTP pour ce serveur PBS. Defaut : valeur de `PVE_TIMEOUT_SECONDS` ou `30`. |
|
| `PBS<number>_TIMEOUT_SECONDS` | Non | Timeout HTTP pour ce serveur PBS. Defaut : valeur de `PVE_TIMEOUT_SECONDS` ou `30`. |
|
||||||
| `LOG_LEVEL` | Non | Niveau de log. Defaut : `INFO`. |
|
| `LOG_LEVEL` | Non | Niveau de log. Defaut : `INFO`. |
|
||||||
| `REPORT_FILENAME_PREFIX` | Non | Prefixe du fichier PDF. |
|
| `REPORT_FILENAME_PREFIX` | Non | Prefixe du fichier PDF. |
|
||||||
|
| `REPORT_EMAIL_ENABLED` | Non | Active l'envoi du PDF par email apres generation. Defaut : `false`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_HOST` | Si email actif | Serveur SMTP. |
|
||||||
|
| `REPORT_EMAIL_SMTP_PORT` | Non | Port SMTP. Defaut : `587`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_STARTTLS` | Non | Active STARTTLS. Defaut : `true`. Incompatible avec `REPORT_EMAIL_SMTP_SSL=true`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_SSL` | Non | Utilise SMTP sur TLS implicite, souvent port `465`. Defaut : `false`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_USERNAME` | Non | Identifiant SMTP. Si renseigne, `REPORT_EMAIL_SMTP_PASSWORD` est obligatoire. |
|
||||||
|
| `REPORT_EMAIL_SMTP_PASSWORD` | Non | Mot de passe SMTP, lu uniquement depuis l'environnement et jamais affiche. |
|
||||||
|
| `REPORT_EMAIL_FROM` | Si email actif | Adresse expediteur du message. |
|
||||||
|
| `REPORT_EMAIL_TO` | Si email actif | Destinataires separes par des virgules. |
|
||||||
|
| `REPORT_EMAIL_SUBJECT` | Non | Sujet du message. Defaut : `Rapport sauvegardes PVE`. |
|
||||||
|
| `REPORT_EMAIL_SMTP_TIMEOUT_SECONDS` | Non | Timeout SMTP. Defaut : `30`. |
|
||||||
|
|
||||||
|
## Envoi du rapport par email
|
||||||
|
|
||||||
|
L'envoi email est optionnel et desactive par defaut. Quand `REPORT_EMAIL_ENABLED=true`, la commande `--generate-pdf` genere d'abord le PDF horodate dans `REPORT_OUTPUT_DIR`, puis l'envoie en piece jointe. Si l'envoi echoue, le PDF reste conserve et la commande retourne une erreur pour que cron ou la supervision puisse l'indiquer.
|
||||||
|
|
||||||
|
Exemple avec STARTTLS :
|
||||||
|
|
||||||
|
```env
|
||||||
|
REPORT_EMAIL_ENABLED=true
|
||||||
|
REPORT_EMAIL_SMTP_HOST=smtp.example.invalid
|
||||||
|
REPORT_EMAIL_SMTP_PORT=587
|
||||||
|
REPORT_EMAIL_SMTP_STARTTLS=true
|
||||||
|
REPORT_EMAIL_SMTP_SSL=false
|
||||||
|
REPORT_EMAIL_SMTP_USERNAME=backup-report@example.invalid
|
||||||
|
REPORT_EMAIL_SMTP_PASSWORD=
|
||||||
|
REPORT_EMAIL_FROM=backup-report@example.invalid
|
||||||
|
REPORT_EMAIL_TO=admin@example.invalid,audit@example.invalid
|
||||||
|
REPORT_EMAIL_SUBJECT=Rapport sauvegardes PVE
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple avec TLS implicite :
|
||||||
|
|
||||||
|
```env
|
||||||
|
REPORT_EMAIL_SMTP_PORT=465
|
||||||
|
REPORT_EMAIL_SMTP_STARTTLS=false
|
||||||
|
REPORT_EMAIL_SMTP_SSL=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne pas renseigner de mot de passe SMTP dans l'image Docker, les logs ou la documentation commitee. Utiliser le fichier `.env`, une variable d'environnement runtime ou un secret Docker monte au lancement.
|
||||||
|
|
||||||
## Exemple Docker Compose
|
## Exemple Docker Compose
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ docker compose run --rm pve-backup-report --generate-pdf
|
|||||||
```
|
```
|
||||||
|
|
||||||
Le rapport est cree dans `REPORT_OUTPUT_DIR`. Le nom contient un horodatage et n'ecrase pas les rapports precedents.
|
Le rapport est cree dans `REPORT_OUTPUT_DIR`. Le nom contient un horodatage et n'ecrase pas les rapports precedents.
|
||||||
|
Si `REPORT_EMAIL_ENABLED=true`, le PDF genere est ensuite envoye aux destinataires `REPORT_EMAIL_TO`.
|
||||||
|
|
||||||
Exemple de fichier genere sur l'hote :
|
Exemple de fichier genere sur l'hote :
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from pve_backup_report.coverage import (
|
|||||||
STATUS_PBS_PLANNED,
|
STATUS_PBS_PLANNED,
|
||||||
analyze_backup_coverage,
|
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.logging_config import configure_logging
|
||||||
from pve_backup_report.pbs_client import PbsApiError, PbsClient
|
from pve_backup_report.pbs_client import PbsApiError, PbsClient
|
||||||
from pve_backup_report.pve_client import PveApiError, PveClient
|
from pve_backup_report.pve_client import PveApiError, PveClient
|
||||||
@@ -212,6 +213,16 @@ def run(argv: Sequence[str] | None = None) -> int:
|
|||||||
return 4
|
return 4
|
||||||
|
|
||||||
logger.info("Rapport PDF genere: %s", pdf_path)
|
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
|
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:
|
if args.debug_last_backup_vmid is not None:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pve_backup_report.i18n import TranslationError, normalize_language
|
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)
|
@dataclass(frozen=True)
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
pve_api_url: str
|
pve_api_url: str
|
||||||
@@ -51,6 +66,7 @@ class AppConfig:
|
|||||||
log_level: str
|
log_level: str
|
||||||
report_filename_prefix: str
|
report_filename_prefix: str
|
||||||
report_language: str = "fr"
|
report_language: str = "fr"
|
||||||
|
email: EmailConfig = field(default_factory=EmailConfig)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured_pbs_servers(self) -> tuple[PbsServerConfig, ...]:
|
def configured_pbs_servers(self) -> tuple[PbsServerConfig, ...]:
|
||||||
@@ -103,10 +119,80 @@ def load_config(env_file: str | Path | None = ".env") -> AppConfig:
|
|||||||
"REPORT_FILENAME_PREFIX",
|
"REPORT_FILENAME_PREFIX",
|
||||||
"rapport-sauvegardes-pve",
|
"rapport-sauvegardes-pve",
|
||||||
),
|
),
|
||||||
|
email=parse_email_config(os.environ),
|
||||||
)
|
)
|
||||||
return config
|
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_ENV_PATTERN = re.compile(r"^PBS(\d+)_([A-Z0-9_]+)$")
|
||||||
PBS_SERVER_REQUIRED_KEYS = ("API_URL", "API_TOKEN_ID", "API_TOKEN_SECRET")
|
PBS_SERVER_REQUIRED_KEYS = ("API_URL", "API_TOKEN_ID", "API_TOKEN_SECRET")
|
||||||
PBS_SERVER_KNOWN_KEYS = {
|
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")
|
raise ConfigError(f"{name} contient une entree vide")
|
||||||
mapping[key] = mapped_value
|
mapping[key] = mapped_value
|
||||||
return mapping
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
+49
-1
@@ -9,7 +9,7 @@ from pve_backup_report.cli import (
|
|||||||
ensure_writable_directory,
|
ensure_writable_directory,
|
||||||
run,
|
run,
|
||||||
)
|
)
|
||||||
from pve_backup_report.config import AppConfig, PbsServerConfig
|
from pve_backup_report.config import AppConfig, EmailConfig, PbsServerConfig
|
||||||
|
|
||||||
|
|
||||||
def test_cli_check_config(tmp_path, monkeypatch) -> None:
|
def test_cli_check_config(tmp_path, monkeypatch) -> None:
|
||||||
@@ -198,3 +198,51 @@ def test_configured_pbs_clients_uses_every_configured_server() -> None:
|
|||||||
finally:
|
finally:
|
||||||
for client in clients:
|
for client in clients:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdf_sends_email_when_enabled(tmp_path, monkeypatch) -> None:
|
||||||
|
pdf_path = tmp_path / "rapport.pdf"
|
||||||
|
pdf_path.write_bytes(b"%PDF-1.7")
|
||||||
|
config = AppConfig(
|
||||||
|
pve_api_url="https://pve.example.invalid:8006",
|
||||||
|
pve_api_token_id="backup-report@pve!report",
|
||||||
|
pve_api_token_secret="secret",
|
||||||
|
report_output_dir=tmp_path,
|
||||||
|
report_timezone="Europe/Paris",
|
||||||
|
pve_verify_tls=True,
|
||||||
|
pve_ca_bundle=None,
|
||||||
|
pve_timeout_seconds=30,
|
||||||
|
pve_backup_jobs_endpoint="/cluster/backup",
|
||||||
|
pve_task_history_limit=500,
|
||||||
|
pve_task_log_limit=5000,
|
||||||
|
pbs_hostnames={},
|
||||||
|
pbs_servers=(),
|
||||||
|
log_level="INFO",
|
||||||
|
report_filename_prefix="rapport-sauvegardes-pve",
|
||||||
|
email=EmailConfig(
|
||||||
|
enabled=True,
|
||||||
|
smtp_host="smtp.example.invalid",
|
||||||
|
smtp_from="report@example.invalid",
|
||||||
|
smtp_to=("admin@example.invalid",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
monkeypatch.setattr("pve_backup_report.cli.load_config", lambda: config)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pve_backup_report.cli.collect_data_or_log_error",
|
||||||
|
lambda loaded_config, label: ReportData(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pve_backup_report.cli.render_pdf",
|
||||||
|
lambda *args: pdf_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"pve_backup_report.cli.send_report_email",
|
||||||
|
lambda email_config, generated_pdf_path: sent.append(
|
||||||
|
(email_config.smtp_host, generated_pdf_path)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert run(["--generate-pdf"]) == 0
|
||||||
|
assert sent == [("smtp.example.invalid", pdf_path)]
|
||||||
|
|||||||
+54
-1
@@ -2,7 +2,7 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pve_backup_report.config import ConfigError, load_config, parse_pbs_servers
|
from pve_backup_report.config import ConfigError, load_config, parse_email_config, parse_pbs_servers
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_from_env_file(tmp_path, monkeypatch) -> None:
|
def test_load_config_from_env_file(tmp_path, monkeypatch) -> None:
|
||||||
@@ -123,3 +123,56 @@ def test_load_config_rejects_invalid_report_language(tmp_path, monkeypatch) -> N
|
|||||||
|
|
||||||
with pytest.raises(ConfigError, match="REPORT_LANGUAGE doit valoir fr ou en"):
|
with pytest.raises(ConfigError, match="REPORT_LANGUAGE doit valoir fr ou en"):
|
||||||
load_config(env_file)
|
load_config(env_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_email_config_disabled_by_default() -> None:
|
||||||
|
config = parse_email_config({})
|
||||||
|
|
||||||
|
assert config.enabled is False
|
||||||
|
assert config.smtp_port == 587
|
||||||
|
assert config.smtp_starttls is True
|
||||||
|
assert config.smtp_ssl is False
|
||||||
|
assert config.smtp_to == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_email_config_enabled() -> None:
|
||||||
|
config = parse_email_config(
|
||||||
|
{
|
||||||
|
"REPORT_EMAIL_ENABLED": "true",
|
||||||
|
"REPORT_EMAIL_SMTP_HOST": "smtp.example.invalid",
|
||||||
|
"REPORT_EMAIL_SMTP_PORT": "465",
|
||||||
|
"REPORT_EMAIL_SMTP_SSL": "true",
|
||||||
|
"REPORT_EMAIL_SMTP_STARTTLS": "false",
|
||||||
|
"REPORT_EMAIL_SMTP_USERNAME": "report@example.invalid",
|
||||||
|
"REPORT_EMAIL_SMTP_PASSWORD": "secret",
|
||||||
|
"REPORT_EMAIL_FROM": "report@example.invalid",
|
||||||
|
"REPORT_EMAIL_TO": "admin@example.invalid,audit@example.invalid",
|
||||||
|
"REPORT_EMAIL_SUBJECT": "Rapport PVE",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.smtp_host == "smtp.example.invalid"
|
||||||
|
assert config.smtp_port == 465
|
||||||
|
assert config.smtp_ssl is True
|
||||||
|
assert config.smtp_starttls is False
|
||||||
|
assert config.smtp_username == "report@example.invalid"
|
||||||
|
assert config.smtp_password == "secret"
|
||||||
|
assert config.smtp_from == "report@example.invalid"
|
||||||
|
assert config.smtp_to == ("admin@example.invalid", "audit@example.invalid")
|
||||||
|
assert config.subject == "Rapport PVE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_email_config_rejects_incomplete_enabled_config() -> None:
|
||||||
|
with pytest.raises(ConfigError, match="configuration email incomplete"):
|
||||||
|
parse_email_config({"REPORT_EMAIL_ENABLED": "true"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_email_config_rejects_conflicting_tls_modes() -> None:
|
||||||
|
with pytest.raises(ConfigError, match="ne peuvent pas etre actifs ensemble"):
|
||||||
|
parse_email_config(
|
||||||
|
{
|
||||||
|
"REPORT_EMAIL_SMTP_SSL": "true",
|
||||||
|
"REPORT_EMAIL_SMTP_STARTTLS": "true",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pve_backup_report.config import EmailConfig
|
||||||
|
from pve_backup_report.email_report import build_report_message, send_report_email
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_message_attaches_pdf(tmp_path: Path) -> None:
|
||||||
|
pdf_path = tmp_path / "rapport.pdf"
|
||||||
|
pdf_path.write_bytes(b"%PDF-1.7")
|
||||||
|
config = EmailConfig(
|
||||||
|
enabled=True,
|
||||||
|
smtp_host="smtp.example.invalid",
|
||||||
|
smtp_from="report@example.invalid",
|
||||||
|
smtp_to=("admin@example.invalid",),
|
||||||
|
subject="Rapport PVE",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = build_report_message(config, pdf_path)
|
||||||
|
|
||||||
|
assert message["Subject"] == "Rapport PVE"
|
||||||
|
assert message["From"] == "report@example.invalid"
|
||||||
|
assert message["To"] == "admin@example.invalid"
|
||||||
|
attachments = list(message.iter_attachments())
|
||||||
|
assert len(attachments) == 1
|
||||||
|
assert attachments[0].get_filename() == "rapport.pdf"
|
||||||
|
assert attachments[0].get_content_type() == "application/pdf"
|
||||||
|
assert attachments[0].get_payload(decode=True) == b"%PDF-1.7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_report_email_uses_starttls_and_auth(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
pdf_path = tmp_path / "rapport.pdf"
|
||||||
|
pdf_path.write_bytes(b"%PDF-1.7")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeSmtp:
|
||||||
|
def __init__(self, host: str, port: int, timeout: int) -> None:
|
||||||
|
calls.append(("connect", host, port, timeout))
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||||
|
calls.append(("close",))
|
||||||
|
|
||||||
|
def starttls(self) -> None:
|
||||||
|
calls.append(("starttls",))
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> None:
|
||||||
|
calls.append(("login", username, password))
|
||||||
|
|
||||||
|
def send_message(self, message) -> None:
|
||||||
|
calls.append(("send", message["To"]))
|
||||||
|
|
||||||
|
monkeypatch.setattr("pve_backup_report.email_report.smtplib.SMTP", FakeSmtp)
|
||||||
|
config = EmailConfig(
|
||||||
|
enabled=True,
|
||||||
|
smtp_host="smtp.example.invalid",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_username="report@example.invalid",
|
||||||
|
smtp_password="secret",
|
||||||
|
smtp_from="report@example.invalid",
|
||||||
|
smtp_to=("admin@example.invalid",),
|
||||||
|
smtp_starttls=True,
|
||||||
|
smtp_timeout_seconds=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
send_report_email(config, pdf_path)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
("connect", "smtp.example.invalid", 587, 12),
|
||||||
|
("starttls",),
|
||||||
|
("login", "report@example.invalid", "secret"),
|
||||||
|
("send", "admin@example.invalid"),
|
||||||
|
("close",),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user