Add email delivery check command

This commit is contained in:
2026-05-20 15:29:19 +02:00
parent 6bdddc3d54
commit 86f7c0589c
8 changed files with 157 additions and 8 deletions
+4
View File
@@ -92,9 +92,11 @@ docker compose build
```sh ```sh
docker compose run --rm pve-backup-report --check-config docker compose run --rm pve-backup-report --check-config
docker compose run --rm pve-backup-report --check-api docker compose run --rm pve-backup-report --check-api
docker compose run --rm pve-backup-report --check-email
``` ```
`--check-api` teste les endpoints PVE principaux. Si `/cluster/backup` retourne `HTTP 403 - Permission check failed (/, Sys.Audit)`, le token fonctionne mais il lui manque le privilège `Sys.Audit` sur `/`. `--check-api` teste les endpoints PVE principaux. Si `/cluster/backup` retourne `HTTP 403 - Permission check failed (/, Sys.Audit)`, le token fonctionne mais il lui manque le privilège `Sys.Audit` sur `/`.
`--check-email` envoie un email de test avec la configuration SMTP du fichier `.env`, sans générer de PDF.
### 📄 Génération du rapport ### 📄 Génération du rapport
@@ -156,6 +158,7 @@ pytest
```sh ```sh
pve-backup-report --check-config pve-backup-report --check-config
pve-backup-report --check-api pve-backup-report --check-api
pve-backup-report --check-email
pve-backup-report --dump-inventory pve-backup-report --dump-inventory
pve-backup-report --dump-coverage pve-backup-report --dump-coverage
pve-backup-report --dump-report-data pve-backup-report --dump-report-data
@@ -170,6 +173,7 @@ Sans installation éditable, depuis le dépôt :
```sh ```sh
PYTHONPATH=src python3 -m pve_backup_report --check-config PYTHONPATH=src python3 -m pve_backup_report --check-config
PYTHONPATH=src python3 -m pve_backup_report --check-api PYTHONPATH=src python3 -m pve_backup_report --check-api
PYTHONPATH=src python3 -m pve_backup_report --check-email
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
+4
View File
@@ -92,9 +92,11 @@ docker compose build
```sh ```sh
docker compose run --rm pve-backup-report --check-config docker compose run --rm pve-backup-report --check-config
docker compose run --rm pve-backup-report --check-api docker compose run --rm pve-backup-report --check-api
docker compose run --rm pve-backup-report --check-email
``` ```
`--check-api` tests the main PVE endpoints. If `/cluster/backup` returns `HTTP 403 - Permission check failed (/, Sys.Audit)`, the token works but is missing the `Sys.Audit` privilege on `/`. `--check-api` tests the main PVE endpoints. If `/cluster/backup` returns `HTTP 403 - Permission check failed (/, Sys.Audit)`, the token works but is missing the `Sys.Audit` privilege on `/`.
`--check-email` sends a test email with the SMTP configuration from `.env`, without generating a PDF.
### 📄 Generating the report ### 📄 Generating the report
@@ -156,6 +158,7 @@ pytest
```sh ```sh
pve-backup-report --check-config pve-backup-report --check-config
pve-backup-report --check-api pve-backup-report --check-api
pve-backup-report --check-email
pve-backup-report --dump-inventory pve-backup-report --dump-inventory
pve-backup-report --dump-coverage pve-backup-report --dump-coverage
pve-backup-report --dump-report-data pve-backup-report --dump-report-data
@@ -170,6 +173,7 @@ Without an editable install, from the repository:
```sh ```sh
PYTHONPATH=src python3 -m pve_backup_report --check-config PYTHONPATH=src python3 -m pve_backup_report --check-config
PYTHONPATH=src python3 -m pve_backup_report --check-api PYTHONPATH=src python3 -m pve_backup_report --check-api
PYTHONPATH=src python3 -m pve_backup_report --check-email
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
+10
View File
@@ -45,6 +45,14 @@ This command tests `/nodes`, `/storage`, `/cluster` and the endpoint configured
If `/cluster/backup` returns `HTTP 403 - Permission check failed (/, Sys.Audit)`, the token works but lacks the `Sys.Audit` privilege on `/`. If `/cluster/backup` returns `HTTP 403 - Permission check failed (/, Sys.Audit)`, the token works but lacks the `Sys.Audit` privilege on `/`.
### Email Delivery Test
```sh
docker compose run --rm pve-backup-report --check-email
```
This command sends a test email with the SMTP configuration from `.env`, without generating a PDF. It requires `REPORT_EMAIL_ENABLED=true` and returns an error if the SMTP connection, STARTTLS, authentication or sending fails.
### PDF Report Generation ### PDF Report Generation
```sh ```sh
@@ -124,6 +132,7 @@ Installed commands:
```sh ```sh
pve-backup-report --check-config pve-backup-report --check-config
pve-backup-report --check-api pve-backup-report --check-api
pve-backup-report --check-email
pve-backup-report --dump-inventory pve-backup-report --dump-inventory
pve-backup-report --dump-coverage pve-backup-report --dump-coverage
pve-backup-report --dump-report-data pve-backup-report --dump-report-data
@@ -137,6 +146,7 @@ Without editable installation:
```sh ```sh
PYTHONPATH=src python3 -m pve_backup_report --check-config PYTHONPATH=src python3 -m pve_backup_report --check-config
PYTHONPATH=src python3 -m pve_backup_report --check-api PYTHONPATH=src python3 -m pve_backup_report --check-api
PYTHONPATH=src python3 -m pve_backup_report --check-email
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
+10
View File
@@ -45,6 +45,14 @@ Cette commande teste `/nodes`, `/storage`, `/cluster` et l'endpoint configure da
Si `/cluster/backup` retourne `HTTP 403 - Permission check failed (/, Sys.Audit)`, le token fonctionne mais il lui manque le privilege `Sys.Audit` sur `/`. Si `/cluster/backup` retourne `HTTP 403 - Permission check failed (/, Sys.Audit)`, le token fonctionne mais il lui manque le privilege `Sys.Audit` sur `/`.
### Test d'envoi email
```sh
docker compose run --rm pve-backup-report --check-email
```
Cette commande envoie un email de test avec la configuration SMTP du fichier `.env`, sans generer de PDF. Elle exige `REPORT_EMAIL_ENABLED=true` et retourne une erreur si la connexion SMTP, STARTTLS, l'authentification ou l'envoi echoue.
### Generation du rapport PDF ### Generation du rapport PDF
```sh ```sh
@@ -124,6 +132,7 @@ Commandes installees :
```sh ```sh
pve-backup-report --check-config pve-backup-report --check-config
pve-backup-report --check-api pve-backup-report --check-api
pve-backup-report --check-email
pve-backup-report --dump-inventory pve-backup-report --dump-inventory
pve-backup-report --dump-coverage pve-backup-report --dump-coverage
pve-backup-report --dump-report-data pve-backup-report --dump-report-data
@@ -137,6 +146,7 @@ Sans installation editable :
```sh ```sh
PYTHONPATH=src python3 -m pve_backup_report --check-config PYTHONPATH=src python3 -m pve_backup_report --check-config
PYTHONPATH=src python3 -m pve_backup_report --check-api PYTHONPATH=src python3 -m pve_backup_report --check-api
PYTHONPATH=src python3 -m pve_backup_report --check-email
PYTHONPATH=src python3 -m pve_backup_report --dump-inventory PYTHONPATH=src python3 -m pve_backup_report --dump-inventory
PYTHONPATH=src python3 -m pve_backup_report --dump-coverage PYTHONPATH=src python3 -m pve_backup_report --dump-coverage
PYTHONPATH=src python3 -m pve_backup_report --dump-report-data PYTHONPATH=src python3 -m pve_backup_report --dump-report-data
+15 -1
View File
@@ -24,7 +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.email_report import EmailReportError, send_report_email, send_test_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
@@ -50,6 +50,11 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="teste les endpoints PVE /nodes, /storage et /cluster/backup puis quitte", help="teste les endpoints PVE /nodes, /storage et /cluster/backup puis quitte",
) )
parser.add_argument(
"--check-email",
action="store_true",
help="envoie un email de test avec la configuration SMTP puis quitte",
)
parser.add_argument( parser.add_argument(
"--dump-inventory", "--dump-inventory",
action="store_true", action="store_true",
@@ -156,6 +161,15 @@ def run(argv: Sequence[str] | None = None) -> int:
) )
return 3 if has_error else 0 return 3 if has_error else 0
if args.check_email:
try:
send_test_email(config.email)
except EmailReportError as exc:
logger.error("Verification email echouee: %s", exc)
return 6
logger.info("Email de test envoye a %s", ", ".join(config.email.smtp_to))
return 0
if args.dump_inventory: if args.dump_inventory:
report_data = collect_data_or_log_error(config, "inventaire") report_data = collect_data_or_log_error(config, "inventaire")
if report_data is None: if report_data is None:
+30 -7
View File
@@ -20,7 +20,25 @@ def send_report_email(config: EmailConfig, pdf_path: Path) -> None:
raise EmailReportError("configuration email incomplete") raise EmailReportError("configuration email incomplete")
message = build_report_message(config, pdf_path) message = build_report_message(config, pdf_path)
send_email_message(config, message)
def send_test_email(config: EmailConfig) -> None:
if not config.enabled:
raise EmailReportError("REPORT_EMAIL_ENABLED=false")
if config.smtp_host is None or config.smtp_from is None or not config.smtp_to:
raise EmailReportError("configuration email incomplete")
message = build_base_message(config, f"[TEST] {config.subject}")
message.set_content(
"Bonjour,\n\n"
"Ceci est un email de test envoye par PVE Backup Report.\n\n"
"Cordialement.\n"
)
send_email_message(config, message)
def send_email_message(config: EmailConfig, message: EmailMessage) -> None:
try: try:
if config.smtp_ssl: if config.smtp_ssl:
with smtplib.SMTP_SSL( with smtplib.SMTP_SSL(
@@ -44,13 +62,7 @@ def send_report_email(config: EmailConfig, pdf_path: Path) -> None:
def build_report_message(config: EmailConfig, pdf_path: Path) -> EmailMessage: def build_report_message(config: EmailConfig, pdf_path: Path) -> EmailMessage:
message = EmailMessage() message = build_base_message(config, config.subject)
message["Subject"] = config.subject
message["From"] = config.smtp_from or ""
message["To"] = ", ".join(config.smtp_to)
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid(domain=message_id_domain(config.smtp_from))
message["Auto-Submitted"] = "auto-generated"
message.set_content( message.set_content(
"Bonjour,\n\n" "Bonjour,\n\n"
"Veuillez trouver ci-joint le rapport de sauvegardes Proxmox VE.\n\n" "Veuillez trouver ci-joint le rapport de sauvegardes Proxmox VE.\n\n"
@@ -65,6 +77,17 @@ def build_report_message(config: EmailConfig, pdf_path: Path) -> EmailMessage:
return message return message
def build_base_message(config: EmailConfig, subject: str) -> EmailMessage:
message = EmailMessage()
message["Subject"] = subject
message["From"] = config.smtp_from or ""
message["To"] = ", ".join(config.smtp_to)
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid(domain=message_id_domain(config.smtp_from))
message["Auto-Submitted"] = "auto-generated"
return message
def message_id_domain(address: str | None) -> str | None: def message_id_domain(address: str | None) -> str | None:
if address is None: if address is None:
return None return None
+42
View File
@@ -41,6 +41,12 @@ def test_cli_has_dump_pbs_users() -> None:
assert args.dump_pbs_users is True assert args.dump_pbs_users is True
def test_cli_has_check_email() -> None:
args = build_parser().parse_args(["--check-email"])
assert args.check_email is True
def test_dump_report_data_does_not_export_sensitive_raw_fields( def test_dump_report_data_does_not_export_sensitive_raw_fields(
monkeypatch, monkeypatch,
capsys, capsys,
@@ -246,3 +252,39 @@ def test_generate_pdf_sends_email_when_enabled(tmp_path, monkeypatch) -> None:
assert run(["--generate-pdf"]) == 0 assert run(["--generate-pdf"]) == 0
assert sent == [("smtp.example.invalid", pdf_path)] assert sent == [("smtp.example.invalid", pdf_path)]
def test_check_email_sends_test_email(monkeypatch) -> None:
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=Path("reports"),
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.send_test_email",
lambda email_config: sent.append(email_config.smtp_host),
)
assert run(["--check-email"]) == 0
assert sent == ["smtp.example.invalid"]
+42
View File
@@ -3,8 +3,10 @@ from pathlib import Path
from pve_backup_report.config import EmailConfig from pve_backup_report.config import EmailConfig
from pve_backup_report.email_report import ( from pve_backup_report.email_report import (
build_report_message, build_report_message,
EmailReportError,
message_id_domain, message_id_domain,
send_report_email, send_report_email,
send_test_email,
) )
@@ -41,6 +43,46 @@ def test_message_id_domain_uses_sender_domain() -> None:
assert message_id_domain(None) is None assert message_id_domain(None) is None
def test_send_test_email_builds_message_without_attachment(monkeypatch) -> None:
sent = []
config = EmailConfig(
enabled=True,
smtp_host="smtp.example.invalid",
smtp_from="report@example.invalid",
smtp_to=("admin@example.invalid",),
subject="Rapport PVE",
)
monkeypatch.setattr(
"pve_backup_report.email_report.send_email_message",
lambda email_config, message: sent.append((email_config, message)),
)
send_test_email(config)
assert sent[0][0] == config
message = sent[0][1]
assert message["Subject"] == "[TEST] Rapport PVE"
assert message["To"] == "admin@example.invalid"
assert list(message.iter_attachments()) == []
def test_send_test_email_requires_enabled_config() -> None:
config = EmailConfig(
enabled=False,
smtp_host="smtp.example.invalid",
smtp_from="report@example.invalid",
smtp_to=("admin@example.invalid",),
)
try:
send_test_email(config)
except EmailReportError as exc:
assert "REPORT_EMAIL_ENABLED=false" in str(exc)
else:
raise AssertionError("EmailReportError attendu")
def test_send_report_email_uses_starttls_and_auth(tmp_path: Path, monkeypatch) -> None: def test_send_report_email_uses_starttls_and_auth(tmp_path: Path, monkeypatch) -> None:
pdf_path = tmp_path / "rapport.pdf" pdf_path = tmp_path / "rapport.pdf"
pdf_path.write_bytes(b"%PDF-1.7") pdf_path.write_bytes(b"%PDF-1.7")