diff --git a/.env.example b/.env.example index 87a4b7e..db3bd8f 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,16 @@ PBS01_CA_BUNDLE= PBS01_TIMEOUT_SECONDS=30 LOG_LEVEL=INFO 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 diff --git a/docs/en/configuration.md b/docs/en/configuration.md index 9c9a50f..c728c8f 100644 --- a/docs/en/configuration.md +++ b/docs/en/configuration.md @@ -28,6 +28,7 @@ PVE_TASK_HISTORY_LIMIT=500 PBS_HOSTNAMES=backup.example.invalid=display-name LOG_LEVEL=INFO REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve +REPORT_EMAIL_ENABLED=false ``` ## Variable Description @@ -56,6 +57,46 @@ REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve | `PBS_TIMEOUT_SECONDS` | No | HTTP timeout for this PBS server. Default: `PVE_TIMEOUT_SECONDS` or `30`. | | `LOG_LEVEL` | No | Log level. Default: `INFO`. | | `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 diff --git a/docs/en/exploitation.md b/docs/en/exploitation.md index 0e780b0..bd48b14 100644 --- a/docs/en/exploitation.md +++ b/docs/en/exploitation.md @@ -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. +If `REPORT_EMAIL_ENABLED=true`, the generated PDF is then sent to the `REPORT_EMAIL_TO` recipients. Example file generated on the host: diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md index e97ffa8..72b8653 100644 --- a/docs/fr/configuration.md +++ b/docs/fr/configuration.md @@ -28,6 +28,7 @@ PVE_TASK_HISTORY_LIMIT=500 PBS_HOSTNAMES=backup.example.invalid=nom-affiche LOG_LEVEL=INFO REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve +REPORT_EMAIL_ENABLED=false ``` ## Description des variables @@ -56,6 +57,46 @@ REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve | `PBS_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`. | | `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 diff --git a/docs/fr/exploitation.md b/docs/fr/exploitation.md index a1837df..99a12da 100644 --- a/docs/fr/exploitation.md +++ b/docs/fr/exploitation.md @@ -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. +Si `REPORT_EMAIL_ENABLED=true`, le PDF genere est ensuite envoye aux destinataires `REPORT_EMAIL_TO`. Exemple de fichier genere sur l'hote : diff --git a/src/pve_backup_report/cli.py b/src/pve_backup_report/cli.py index 4d59ff3..c6ddc8c 100644 --- a/src/pve_backup_report/cli.py +++ b/src/pve_backup_report/cli.py @@ -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: diff --git a/src/pve_backup_report/config.py b/src/pve_backup_report/config.py index 5b56990..870b21e 100644 --- a/src/pve_backup_report/config.py +++ b/src/pve_backup_report/config.py @@ -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 diff --git a/src/pve_backup_report/email_report.py b/src/pve_backup_report/email_report.py new file mode 100644 index 0000000..fefeffa --- /dev/null +++ b/src/pve_backup_report/email_report.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3347e54..f4bd620 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,7 @@ from pve_backup_report.cli import ( ensure_writable_directory, 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: @@ -198,3 +198,51 @@ def test_configured_pbs_clients_uses_every_configured_server() -> None: finally: for client in clients: 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)] diff --git a/tests/test_config.py b/tests/test_config.py index 3fccad6..8e80779 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ import os 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: @@ -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"): 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", + } + ) diff --git a/tests/test_email_report.py b/tests/test_email_report.py new file mode 100644 index 0000000..741efa2 --- /dev/null +++ b/tests/test_email_report.py @@ -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",), + ]