diff --git a/README.fr.md b/README.fr.md index 5fbf5ee..f004abe 100644 --- a/README.fr.md +++ b/README.fr.md @@ -92,9 +92,11 @@ docker compose build ```sh 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-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-email` envoie un email de test avec la configuration SMTP du fichier `.env`, sans générer de PDF. ### 📄 Génération du rapport @@ -156,6 +158,7 @@ pytest ```sh pve-backup-report --check-config pve-backup-report --check-api +pve-backup-report --check-email pve-backup-report --dump-inventory pve-backup-report --dump-coverage pve-backup-report --dump-report-data @@ -170,6 +173,7 @@ Sans installation éditable, depuis le dépôt : ```sh 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-email 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-report-data diff --git a/README.md b/README.md index 6bcc9aa..4c768e9 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,11 @@ docker compose build ```sh 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-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-email` sends a test email with the SMTP configuration from `.env`, without generating a PDF. ### 📄 Generating the report @@ -156,6 +158,7 @@ pytest ```sh pve-backup-report --check-config pve-backup-report --check-api +pve-backup-report --check-email pve-backup-report --dump-inventory pve-backup-report --dump-coverage pve-backup-report --dump-report-data @@ -170,6 +173,7 @@ Without an editable install, from the repository: ```sh 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-email 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-report-data diff --git a/docs/en/exploitation.md b/docs/en/exploitation.md index bd48b14..ea4f78b 100644 --- a/docs/en/exploitation.md +++ b/docs/en/exploitation.md @@ -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 `/`. +### 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 ```sh @@ -124,6 +132,7 @@ Installed commands: ```sh pve-backup-report --check-config pve-backup-report --check-api +pve-backup-report --check-email pve-backup-report --dump-inventory pve-backup-report --dump-coverage pve-backup-report --dump-report-data @@ -137,6 +146,7 @@ Without editable installation: ```sh 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-email 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-report-data diff --git a/docs/fr/exploitation.md b/docs/fr/exploitation.md index 99a12da..1aac6ba 100644 --- a/docs/fr/exploitation.md +++ b/docs/fr/exploitation.md @@ -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 `/`. +### 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 ```sh @@ -124,6 +132,7 @@ Commandes installees : ```sh pve-backup-report --check-config pve-backup-report --check-api +pve-backup-report --check-email pve-backup-report --dump-inventory pve-backup-report --dump-coverage pve-backup-report --dump-report-data @@ -137,6 +146,7 @@ Sans installation editable : ```sh 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-email 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-report-data diff --git a/src/pve_backup_report/cli.py b/src/pve_backup_report/cli.py index c6ddc8c..745dd1c 100644 --- a/src/pve_backup_report/cli.py +++ b/src/pve_backup_report/cli.py @@ -24,7 +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.email_report import EmailReportError, send_report_email, send_test_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 @@ -50,6 +50,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", 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( "--dump-inventory", action="store_true", @@ -156,6 +161,15 @@ def run(argv: Sequence[str] | None = None) -> int: ) 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: report_data = collect_data_or_log_error(config, "inventaire") if report_data is None: diff --git a/src/pve_backup_report/email_report.py b/src/pve_backup_report/email_report.py index 058f6f5..2c20fc7 100644 --- a/src/pve_backup_report/email_report.py +++ b/src/pve_backup_report/email_report.py @@ -20,7 +20,25 @@ def send_report_email(config: EmailConfig, pdf_path: Path) -> None: raise EmailReportError("configuration email incomplete") 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: if config.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: - message = EmailMessage() - 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 = build_base_message(config, config.subject) message.set_content( "Bonjour,\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 +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: if address is None: return None diff --git a/tests/test_cli.py b/tests/test_cli.py index f4bd620..3460cb0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,6 +41,12 @@ def test_cli_has_dump_pbs_users() -> None: 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( monkeypatch, capsys, @@ -246,3 +252,39 @@ def test_generate_pdf_sends_email_when_enabled(tmp_path, monkeypatch) -> None: assert run(["--generate-pdf"]) == 0 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"] diff --git a/tests/test_email_report.py b/tests/test_email_report.py index 81064bf..7322090 100644 --- a/tests/test_email_report.py +++ b/tests/test_email_report.py @@ -3,8 +3,10 @@ from pathlib import Path from pve_backup_report.config import EmailConfig from pve_backup_report.email_report import ( build_report_message, + EmailReportError, message_id_domain, 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 +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: pdf_path = tmp_path / "rapport.pdf" pdf_path.write_bytes(b"%PDF-1.7")