feat: E-Mail-Benachrichtigung per SMTP (STARTTLS/SSL), Passwort verschlüsselt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b6b6bf6bd7
commit
a8bf4ff67e
@@ -0,0 +1,91 @@
|
|||||||
|
"""E-Mail-Benachrichtigung per SMTP.
|
||||||
|
|
||||||
|
* Verschlüsselung: ``starttls`` (Port 587, Standard), ``ssl`` (Port 465) oder ``none``
|
||||||
|
(nur für interne Relays ohne TLS). Zertifikate werden immer geprüft.
|
||||||
|
* Das Passwort liegt Fernet-verschlüsselt in den Einstellungen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
from datetime import datetime
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
|
|
||||||
|
from netmover.settings_store import AppSettings
|
||||||
|
|
||||||
|
SECURITY_MODES = ("starttls", "ssl", "none")
|
||||||
|
_ADDRESS = re.compile(r"^[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+$")
|
||||||
|
|
||||||
|
|
||||||
|
class MailConfigError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_recipients(raw: str) -> list[str]:
|
||||||
|
recipients = [r.strip() for r in re.split(r"[,;\n]", raw) if r.strip()]
|
||||||
|
bad = [r for r in recipients if not _ADDRESS.fullmatch(r)]
|
||||||
|
if bad:
|
||||||
|
raise MailConfigError(f"Ungültige Adresse: {', '.join(bad)}")
|
||||||
|
return recipients
|
||||||
|
|
||||||
|
|
||||||
|
def valid_address(value: str) -> bool:
|
||||||
|
return bool(_ADDRESS.fullmatch(value.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def mail_configured(cfg: AppSettings) -> bool:
|
||||||
|
return bool(cfg.email_enabled and cfg.smtp_host and cfg.smtp_from and cfg.smtp_to)
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(cfg: AppSettings, subject: str, body: str) -> EmailMessage:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = cfg.smtp_from
|
||||||
|
msg["To"] = ", ".join(parse_recipients(cfg.smtp_to))
|
||||||
|
msg["Date"] = formatdate(localtime=True)
|
||||||
|
msg["Message-ID"] = make_msgid(domain=cfg.smtp_from.rsplit("@", 1)[-1])
|
||||||
|
msg["X-Mailer"] = "NetMover"
|
||||||
|
footer = (
|
||||||
|
f"\n\n-- \nNetMover auf {socket.gethostname()}, "
|
||||||
|
f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z}"
|
||||||
|
)
|
||||||
|
msg.set_content(body + footer)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def tls_context() -> ssl.SSLContext:
|
||||||
|
"""Zertifikatsprüfung gegen die System-CAs (eigene Funktion, damit Tests ihr
|
||||||
|
Test-Zertifikat einsetzen können, ohne die Prüfung abzuschalten)."""
|
||||||
|
return ssl.create_default_context()
|
||||||
|
|
||||||
|
|
||||||
|
def smtp_send(
|
||||||
|
cfg: AppSettings, password: str | None, subject: str, body: str, timeout: float = 20
|
||||||
|
) -> None:
|
||||||
|
"""Verschickt eine Nachricht; Fehler werden als Ausnahme weitergereicht."""
|
||||||
|
if cfg.smtp_security not in SECURITY_MODES:
|
||||||
|
raise MailConfigError(f"Unbekannte Verschlüsselung: {cfg.smtp_security}")
|
||||||
|
msg = build_message(cfg, subject, body)
|
||||||
|
context = tls_context()
|
||||||
|
smtp: smtplib.SMTP
|
||||||
|
if cfg.smtp_security == "ssl":
|
||||||
|
smtp = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=timeout, context=context)
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=timeout)
|
||||||
|
try:
|
||||||
|
smtp.ehlo()
|
||||||
|
if cfg.smtp_security == "starttls":
|
||||||
|
smtp.starttls(context=context)
|
||||||
|
smtp.ehlo()
|
||||||
|
if cfg.smtp_user:
|
||||||
|
smtp.login(cfg.smtp_user, password or "")
|
||||||
|
smtp.send_message(msg)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
smtp.quit()
|
||||||
|
except smtplib.SMTPException:
|
||||||
|
smtp.close()
|
||||||
+44
-5
@@ -22,7 +22,9 @@ from string import Template
|
|||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from netmover.crypto import SecretBox
|
||||||
from netmover.logging_setup import event, get_logger
|
from netmover.logging_setup import event, get_logger
|
||||||
|
from netmover.mail import mail_configured, smtp_send
|
||||||
from netmover.settings_store import AppSettings, NotifyMode, load_settings
|
from netmover.settings_store import AppSettings, NotifyMode, load_settings
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
@@ -60,34 +62,71 @@ def http_sender(url: str, content_type: str, body: str) -> None:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
Mailer = Callable[[AppSettings, "str | None", str, str], None] # (cfg, passwort, betreff, text)
|
||||||
|
|
||||||
|
|
||||||
class Notifier:
|
class Notifier:
|
||||||
|
"""Verteilt Benachrichtigungen auf die eingerichteten Kanäle (Webhook, E-Mail)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session_factory: sessionmaker[Session],
|
session_factory: sessionmaker[Session],
|
||||||
*,
|
*,
|
||||||
sender: Sender = http_sender,
|
sender: Sender = http_sender,
|
||||||
|
mailer: Mailer | None = None,
|
||||||
|
secrets: SecretBox | None = None,
|
||||||
background: bool = True,
|
background: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self._sender = sender
|
self._sender = sender
|
||||||
|
self._mailer: Mailer = mailer or smtp_send
|
||||||
|
self._secrets = secrets
|
||||||
self._background = background
|
self._background = background
|
||||||
|
|
||||||
def settings(self) -> AppSettings:
|
def settings(self) -> AppSettings:
|
||||||
return load_settings(self.session_factory)
|
return load_settings(self.session_factory)
|
||||||
|
|
||||||
|
def smtp_password(self, cfg: AppSettings) -> str | None:
|
||||||
|
if not cfg.smtp_password_enc or self._secrets is None:
|
||||||
|
return None
|
||||||
|
return self._secrets.decrypt(cfg.smtp_password_enc)
|
||||||
|
|
||||||
|
def send_mail_now(self, subject: str, body: str, settings: AppSettings | None = None) -> None:
|
||||||
|
"""Synchron senden (Test-Knopf); Fehler werden weitergereicht."""
|
||||||
|
cfg = settings or self.settings()
|
||||||
|
self._mailer(cfg, self.smtp_password(cfg), subject, body)
|
||||||
|
|
||||||
def send(self, n: Notification, settings: AppSettings | None = None) -> None:
|
def send(self, n: Notification, settings: AppSettings | None = None) -> None:
|
||||||
cfg = settings or self.settings()
|
cfg = settings or self.settings()
|
||||||
if cfg.notify_mode is NotifyMode.OFF or not cfg.webhook_url:
|
if cfg.notify_mode is NotifyMode.OFF:
|
||||||
return
|
return
|
||||||
|
jobs: list[tuple[str, Callable[[], None]]] = []
|
||||||
|
if cfg.webhook_url:
|
||||||
body = render(cfg.webhook_template, cfg.webhook_content_type, n)
|
body = render(cfg.webhook_template, cfg.webhook_content_type, n)
|
||||||
|
jobs.append(
|
||||||
|
("webhook", lambda: self._sender(cfg.webhook_url, cfg.webhook_content_type, body))
|
||||||
|
)
|
||||||
|
if mail_configured(cfg):
|
||||||
|
password = self.smtp_password(cfg)
|
||||||
|
jobs.append(("email", lambda: self._mailer(cfg, password, n.title, n.message)))
|
||||||
|
if not jobs:
|
||||||
|
return
|
||||||
|
|
||||||
def deliver() -> None:
|
def deliver() -> None:
|
||||||
|
for channel, job in jobs:
|
||||||
try:
|
try:
|
||||||
self._sender(cfg.webhook_url, cfg.webhook_content_type, body)
|
job()
|
||||||
event(log, "notify.sent", kind=n.event, rule=n.rule)
|
event(log, "notify.sent", kind=n.event, rule=n.rule, channel=channel)
|
||||||
except Exception as exc: # Benachrichtigung darf nie den Dienst stören
|
except Exception as exc: # Benachrichtigung darf nie den Dienst stören
|
||||||
# URL nicht protokollieren – sie enthält oft ein Token.
|
# Weder URL noch Zugangsdaten protokollieren.
|
||||||
event(log, "notify.failed", level=30, kind=n.event, error=type(exc).__name__)
|
event(
|
||||||
|
log,
|
||||||
|
"notify.failed",
|
||||||
|
level=30,
|
||||||
|
kind=n.event,
|
||||||
|
channel=channel,
|
||||||
|
error=type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
if self._background:
|
if self._background:
|
||||||
threading.Thread(target=deliver, name="nm-notify", daemon=True).start()
|
threading.Thread(target=deliver, name="nm-notify", daemon=True).start()
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""E-Mail-Benachrichtigung gegen einen echten SMTP-Server im Testprozess (aiosmtpd)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from email import message_from_bytes
|
||||||
|
from email.policy import default
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiosmtpd.controller import Controller
|
||||||
|
from aiosmtpd.smtp import AuthResult, LoginPassword
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from netmover.crypto import SecretBox
|
||||||
|
from netmover.mail import MailConfigError, parse_recipients, smtp_send
|
||||||
|
from netmover.notify import Notification, Notifier
|
||||||
|
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
|
||||||
|
from tests.webutil import post
|
||||||
|
|
||||||
|
|
||||||
|
class Inbox:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages: list[tuple[list[str], Any]] = []
|
||||||
|
|
||||||
|
async def handle_DATA(self, server: Any, session: Any, envelope: Any) -> str:
|
||||||
|
self.messages.append(
|
||||||
|
(envelope.rcpt_tos, message_from_bytes(envelope.content, policy=default))
|
||||||
|
)
|
||||||
|
return "250 OK"
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
s = socket.socket()
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
port: int = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(server: Any, session: Any, envelope: Any, mechanism: str, data: Any) -> AuthResult:
|
||||||
|
ok = (
|
||||||
|
isinstance(data, LoginPassword)
|
||||||
|
and data.login == b"netmover"
|
||||||
|
and data.password == b"Pa$$,wort"
|
||||||
|
)
|
||||||
|
return AuthResult(success=ok, handled=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def tls_files(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
|
||||||
|
d = tmp_path_factory.mktemp("tls")
|
||||||
|
key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
|
||||||
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(name)
|
||||||
|
.issuer_name(name)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(days=1))
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=1))
|
||||||
|
.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False)
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
(d / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
(d / "key.pem").write_bytes(
|
||||||
|
key.private_bytes(
|
||||||
|
serialization.Encoding.PEM,
|
||||||
|
serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return d / "cert.pem", d / "key.pem"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def trust(tls_files: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Client vertraut unserem Test-Zertifikat (Prüfung bleibt eingeschaltet)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"netmover.mail.tls_context", lambda: ssl.create_default_context(cafile=str(tls_files[0]))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _server(mode: str, tls_files: tuple[Path, Path]) -> tuple[Controller, Inbox, int]:
|
||||||
|
inbox, port = Inbox(), _free_port()
|
||||||
|
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||||
|
ctx.load_cert_chain(*map(str, tls_files))
|
||||||
|
kw: dict[str, Any] = {
|
||||||
|
"hostname": "localhost",
|
||||||
|
"port": port,
|
||||||
|
"authenticator": _auth,
|
||||||
|
"auth_exclude_mechanism": ["LOGIN"],
|
||||||
|
}
|
||||||
|
if mode == "starttls":
|
||||||
|
kw.update(tls_context=ctx, require_starttls=True)
|
||||||
|
elif mode == "ssl":
|
||||||
|
# implizites TLS erkennt aiosmtpd nicht als „verschlüsselt“ – AUTH trotzdem anbieten
|
||||||
|
kw.update(ssl_context=ctx, auth_require_tls=False)
|
||||||
|
else:
|
||||||
|
kw.update(auth_require_tls=False)
|
||||||
|
controller = Controller(inbox, **kw)
|
||||||
|
controller.start()
|
||||||
|
return controller, inbox, port
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["starttls", "ssl", "none"])
|
||||||
|
def smtp_server(
|
||||||
|
request: pytest.FixtureRequest, tls_files: tuple[Path, Path], trust: None
|
||||||
|
) -> Iterator[tuple[str, Inbox, int]]:
|
||||||
|
controller, inbox, port = _server(request.param, tls_files)
|
||||||
|
yield request.param, inbox, port
|
||||||
|
controller.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(mode: str, port: int, **kw: Any) -> AppSettings:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"email_enabled": True,
|
||||||
|
"smtp_host": "localhost",
|
||||||
|
"smtp_port": port,
|
||||||
|
"smtp_security": mode,
|
||||||
|
"smtp_user": "netmover",
|
||||||
|
"smtp_from": "netmover@example.org",
|
||||||
|
"smtp_to": "ich@example.org; admin@example.org",
|
||||||
|
"notify_mode": NotifyMode.EVERY,
|
||||||
|
}
|
||||||
|
data.update(kw)
|
||||||
|
return AppSettings(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_send_all_security_modes(smtp_server: tuple[str, Inbox, int]) -> None:
|
||||||
|
mode, inbox, port = smtp_server
|
||||||
|
smtp_send(_cfg(mode, port), "Pa$$,wort", "NetMover: Anbindung „NAS“ gestört", "Host is down")
|
||||||
|
((rcpts, msg),) = inbox.messages
|
||||||
|
assert rcpts == ["ich@example.org", "admin@example.org"]
|
||||||
|
assert msg["Subject"] == "NetMover: Anbindung „NAS“ gestört"
|
||||||
|
assert msg["From"] == "netmover@example.org"
|
||||||
|
body = msg.get_content()
|
||||||
|
assert "Host is down" in body and "NetMover auf" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_raises(smtp_server: tuple[str, Inbox, int]) -> None:
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
mode, inbox, port = smtp_server
|
||||||
|
with pytest.raises(smtplib.SMTPAuthenticationError):
|
||||||
|
smtp_send(_cfg(mode, port), "falsch", "x", "y")
|
||||||
|
assert inbox.messages == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_certificate_is_verified(tls_files: tuple[Path, Path]) -> None:
|
||||||
|
"""Ohne Vertrauen in das Zertifikat wird abgelehnt – keine stille Herabstufung."""
|
||||||
|
controller, inbox, port = _server("starttls", tls_files)
|
||||||
|
try:
|
||||||
|
with pytest.raises(ssl.SSLCertVerificationError):
|
||||||
|
smtp_send(_cfg("starttls", port), "Pa$$,wort", "x", "y")
|
||||||
|
finally:
|
||||||
|
controller.stop()
|
||||||
|
assert inbox.messages == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipients() -> None:
|
||||||
|
assert parse_recipients("a@b.de, c@d.org;e@f.net\n") == ["a@b.de", "c@d.org", "e@f.net"]
|
||||||
|
with pytest.raises(MailConfigError, match="kein-mail"):
|
||||||
|
parse_recipients("a@b.de, kein-mail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifier_sends_webhook_and_mail(
|
||||||
|
session_factory: sessionmaker[Session], secretbox: SecretBox
|
||||||
|
) -> None:
|
||||||
|
hooks: list[Any] = []
|
||||||
|
mails: list[Any] = []
|
||||||
|
cfg = _cfg(
|
||||||
|
"starttls",
|
||||||
|
587,
|
||||||
|
smtp_password_enc=secretbox.encrypt("Pa$$,wort"),
|
||||||
|
webhook_url="http://hook.invalid/x",
|
||||||
|
)
|
||||||
|
save_settings(session_factory, cfg)
|
||||||
|
n = Notifier(
|
||||||
|
session_factory,
|
||||||
|
sender=lambda *a: hooks.append(a),
|
||||||
|
mailer=lambda *a: mails.append(a),
|
||||||
|
secrets=secretbox,
|
||||||
|
background=False,
|
||||||
|
)
|
||||||
|
n.send(Notification("mount.error", "NetMover: Anbindung „NAS“ gestört", "weg"))
|
||||||
|
assert len(hooks) == 1 and len(mails) == 1
|
||||||
|
_cfg_used, password, subject, body = mails[0]
|
||||||
|
assert password == "Pa$$,wort" and subject.endswith("gestört") and body == "weg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_only_and_mode_off(
|
||||||
|
session_factory: sessionmaker[Session], secretbox: SecretBox
|
||||||
|
) -> None:
|
||||||
|
mails: list[Any] = []
|
||||||
|
n = Notifier(
|
||||||
|
session_factory, mailer=lambda *a: mails.append(a), secrets=secretbox, background=False
|
||||||
|
)
|
||||||
|
save_settings(session_factory, _cfg("starttls", 587, notify_mode=NotifyMode.OFF))
|
||||||
|
n.send(Notification("x", "t", "m"))
|
||||||
|
save_settings(session_factory, _cfg("starttls", 587, email_enabled=False))
|
||||||
|
n.send(Notification("x", "t", "m"))
|
||||||
|
assert mails == []
|
||||||
|
save_settings(session_factory, _cfg("starttls", 587)) # nur E-Mail, kein Webhook
|
||||||
|
n.send(Notification("x", "t", "m"))
|
||||||
|
assert len(mails) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_failure_is_swallowed(
|
||||||
|
session_factory: sessionmaker[Session], secretbox: SecretBox
|
||||||
|
) -> None:
|
||||||
|
def broken(*a: Any) -> None:
|
||||||
|
raise ConnectionRefusedError("weg")
|
||||||
|
|
||||||
|
save_settings(session_factory, _cfg("starttls", 587))
|
||||||
|
Notifier(session_factory, mailer=broken, secrets=secretbox, background=False).send(
|
||||||
|
Notification("x", "t", "m")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Einstellungsseite ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _form(**kw: str) -> dict[str, str]:
|
||||||
|
data = {
|
||||||
|
"retention_days": "30",
|
||||||
|
"notify_mode": "every",
|
||||||
|
"notify_after": "3",
|
||||||
|
"webhook_url": "",
|
||||||
|
"webhook_content_type": "application/json",
|
||||||
|
"webhook_template": "$message",
|
||||||
|
"email_enabled": "on",
|
||||||
|
"smtp_host": "smtp.example.org",
|
||||||
|
"smtp_port": "587",
|
||||||
|
"smtp_security": "starttls",
|
||||||
|
"smtp_user": "netmover",
|
||||||
|
"smtp_password": "Geheim,123!",
|
||||||
|
"smtp_from": "netmover@example.org",
|
||||||
|
"smtp_to": "ich@example.org",
|
||||||
|
}
|
||||||
|
data.update(kw)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_store_encrypted_password(client: TestClient, state: Any) -> None:
|
||||||
|
assert post(client, "/settings", _form()).status_code == 303
|
||||||
|
cfg = load_settings(state.session_factory)
|
||||||
|
assert cfg.email_enabled and "Geheim" not in cfg.smtp_password_enc
|
||||||
|
assert state.secrets.decrypt(cfg.smtp_password_enc) == "Geheim,123!"
|
||||||
|
page = client.get("/settings").text
|
||||||
|
assert "Geheim,123!" not in page and 'placeholder="••••••"' in page
|
||||||
|
# leer lassen = unverändert
|
||||||
|
post(client, "/settings", _form(smtp_password=""))
|
||||||
|
assert (
|
||||||
|
state.secrets.decrypt(load_settings(state.session_factory).smtp_password_enc)
|
||||||
|
== "Geheim,123!"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_validation(client: TestClient) -> None:
|
||||||
|
r = post(
|
||||||
|
client,
|
||||||
|
"/settings",
|
||||||
|
_form(smtp_to="ich@example.org, quatsch", smtp_port="99999", smtp_from="kein-absender"),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
for msg in ("Ungültige Adresse: quatsch", "Port zwischen", "Keine gültige E-Mail-Adresse"):
|
||||||
|
assert msg in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_test_button(
|
||||||
|
client: TestClient, state: Any, tls_files: tuple[Path, Path], trust: None
|
||||||
|
) -> None:
|
||||||
|
controller, inbox, port = _server("starttls", tls_files)
|
||||||
|
try:
|
||||||
|
post(
|
||||||
|
client,
|
||||||
|
"/settings",
|
||||||
|
_form(smtp_host="localhost", smtp_port=str(port), smtp_password="Pa$$,wort"),
|
||||||
|
)
|
||||||
|
r = post(client, "/settings/mail-test")
|
||||||
|
assert "Gesendet an ich@example.org" in r.text
|
||||||
|
post(
|
||||||
|
client,
|
||||||
|
"/settings",
|
||||||
|
_form(smtp_host="localhost", smtp_port=str(port), smtp_password="falsch"),
|
||||||
|
)
|
||||||
|
r = post(client, "/settings/mail-test")
|
||||||
|
assert "Fehlgeschlagen" in r.text and "falsch" not in r.text
|
||||||
|
finally:
|
||||||
|
controller.stop()
|
||||||
|
assert len(inbox.messages) == 1
|
||||||
Reference in New Issue
Block a user