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
+13
View File
@@ -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
+41
View File
@@ -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
+1
View File
@@ -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:
+41
View File
@@ -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
+1
View File
@@ -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 :
+11
View File
@@ -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:
+97 -1
View File
@@ -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
+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)
+49 -1
View File
@@ -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
View File
@@ -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",
}
)
+75
View File
@@ -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",),
]