feat: Add email settings management and templates functionality
All checks were successful
CI / test (3.11) (push) Successful in 1m36s
CI / build-image (push) Successful in 1m27s

- Implemented email settings configuration in the admin panel, allowing for SMTP settings and notification preferences.
- Created a new template for email settings with fields for SMTP host, port, username, password, sender address, and recipients.
- Added JavaScript functionality to handle loading, saving, and validating email settings.
- Introduced email templates management, enabling the listing, editing, and saving of email templates.
- Updated navigation to include links to email settings and templates.
- Added tests for email settings and templates to ensure proper functionality and validation.
This commit is contained in:
2025-11-15 11:12:23 +01:00
parent 2629f6b25f
commit e192086833
19 changed files with 1537 additions and 192 deletions

View File

@@ -28,3 +28,35 @@ def setup_tmp_db(tmp_path, monkeypatch):
monkeypatch.setattr("server.settings.ADMIN_PASSWORD", "admin")
init_db()
yield
@pytest.fixture(autouse=True, scope="function")
def stub_smtp(monkeypatch):
"""Replace smtplib SMTP clients with fast stubs to avoid real network calls."""
class _DummySMTP:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def starttls(self):
return None
def login(self, *args, **kwargs):
return None
def send_message(self, *args, **kwargs):
return {}
def sendmail(self, *args, **kwargs):
return {}
monkeypatch.setattr("smtplib.SMTP", _DummySMTP)
monkeypatch.setattr("smtplib.SMTP_SSL", _DummySMTP)
yield

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
import importlib
import pytest
server_app_module = importlib.import_module("server.app")
app = server_app_module.app
@pytest.fixture
def client():
with app.test_client() as client:
yield client
def login(client):
return client.post("/auth/login", data={"username": "admin", "password": "admin"})
def test_email_settings_get_requires_auth(client):
resp = client.get("/admin/api/email-settings")
assert resp.status_code == 302
assert resp.headers["Location"] == "/auth/login"
def test_email_settings_get_with_auth_returns_defaults(client):
login(client)
resp = client.get("/admin/api/email-settings")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
assert isinstance(data["settings"], dict)
assert "smtp_host" in data["settings"]
assert "schema" in data
assert "smtp_host" in data["schema"]
def test_email_settings_update_validation_error(client):
login(client)
payload = {
"smtp_host": "",
"smtp_port": 70000,
"smtp_sender": "not-an-email",
"smtp_recipients": "owner@example.com, invalid",
}
resp = client.put("/admin/api/email-settings", json=payload)
assert resp.status_code == 400
data = resp.get_json()
assert data["status"] == "error"
assert "smtp_host" in data["errors"]
assert "smtp_port" in data["errors"]
assert "smtp_sender" in data["errors"]
assert "smtp_recipients" in data["errors"]
def test_email_settings_update_persists_and_returns_values(client):
login(client)
payload = {
"smtp_host": "smtp.acme.test",
"smtp_port": 2525,
"smtp_username": "mailer",
"smtp_password": "secret",
"smtp_sender": "robot@acme.test",
"smtp_recipients": "alerts@acme.test, ops@acme.test",
"smtp_use_tls": True,
"notify_contact_form": True,
"notify_newsletter_signups": False,
}
resp = client.put("/admin/api/email-settings", json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
assert data["settings"]["smtp_port"] == 2525
assert data["settings"]["smtp_use_tls"] is True
assert data["settings"]["smtp_recipients"] == [
"alerts@acme.test",
"ops@acme.test",
]
assert data["settings"]["notify_contact_form"] is True
assert data["settings"]["notify_newsletter_signups"] is False
# Fetch again to verify persistence
resp_get = client.get("/admin/api/email-settings")
assert resp_get.status_code == 200
stored = resp_get.get_json()["settings"]
assert stored["smtp_host"] == "smtp.acme.test"
assert stored["smtp_port"] == 2525
assert stored["smtp_sender"] == "robot@acme.test"
assert stored["smtp_recipients"] == [
"alerts@acme.test",
"ops@acme.test",
]

View File

@@ -8,16 +8,16 @@ import pytest
from server.services import contact as contact_service # noqa: E402 pylint: disable=wrong-import-position
@pytest.fixture
def patched_settings(monkeypatch):
original = contact_service.settings.SMTP_SETTINGS.copy()
patched = original.copy()
monkeypatch.setattr(contact_service.settings, "SMTP_SETTINGS", patched)
return patched
def test_send_notification_returns_false_when_unconfigured(monkeypatch, patched_settings):
patched_settings.update({"host": None, "recipients": []})
def test_send_notification_returns_false_when_unconfigured(monkeypatch):
monkeypatch.setattr(
contact_service,
"load_effective_smtp_settings",
lambda: {
"notify_contact_form": True,
"host": None,
"recipients": [],
},
)
# Ensure we do not accidentally open a socket if called
monkeypatch.setattr(contact_service.smtplib, "SMTP", pytest.fail)
@@ -33,17 +33,22 @@ def test_send_notification_returns_false_when_unconfigured(monkeypatch, patched_
assert contact_service.send_notification(submission) is False
def test_send_notification_sends_email(monkeypatch, patched_settings):
patched_settings.update(
{
"host": "smtp.example.com",
"port": 2525,
"sender": "sender@example.com",
"username": "user",
"password": "secret",
"use_tls": True,
"recipients": ["owner@example.com"],
}
def test_send_notification_sends_email(monkeypatch):
smtp_config = {
"host": "smtp.example.com",
"port": 2525,
"sender": "sender@example.com",
"username": "user",
"password": "secret",
"use_tls": True,
"recipients": ["owner@example.com"],
"notify_contact_form": True,
}
monkeypatch.setattr(
contact_service,
"load_effective_smtp_settings",
lambda: smtp_config,
)
smtp_calls: dict[str, Any] = {}
@@ -80,13 +85,13 @@ def test_send_notification_sends_email(monkeypatch, patched_settings):
assert contact_service.send_notification(submission) is True
assert smtp_calls["init"] == (
patched_settings["host"],
patched_settings["port"],
smtp_config["host"],
smtp_config["port"],
15,
)
assert smtp_calls["starttls"] is True
assert smtp_calls["login"] == (
patched_settings["username"], patched_settings["password"])
smtp_config["username"], smtp_config["password"])
message = cast(EmailMessage, smtp_calls["message"])
assert message["Subject"] == "Neue Kontaktanfrage von Alice"

View File

@@ -0,0 +1,167 @@
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from server.services import email_settings
def _defaults() -> Dict[str, Any]:
return {
field: meta["default"]
for field, meta in email_settings.EMAIL_SETTINGS_DEFINITIONS.items()
}
def test_load_email_settings_returns_defaults_when_storage_empty(monkeypatch):
monkeypatch.setattr(email_settings, "get_app_settings", lambda: {})
settings = email_settings.load_email_settings()
assert settings == _defaults()
def test_load_email_settings_deserializes_persisted_values(monkeypatch):
stored = {
"email_smtp_host": "smtp.acme.test",
"email_smtp_port": "2525",
"email_smtp_username": "mailer",
"email_smtp_password": "sup3rs3cret",
"email_smtp_sender": "robot@acme.test",
"email_smtp_use_tls": "false",
"email_smtp_recipients": "alerts@acme.test, ops@acme.test",
"email_notify_contact_form": "true",
"email_notify_newsletter_signups": "false",
}
monkeypatch.setattr(email_settings, "get_app_settings", lambda: stored)
settings = email_settings.load_email_settings()
assert settings["smtp_host"] == "smtp.acme.test"
assert settings["smtp_port"] == 2525
assert settings["smtp_username"] == "mailer"
assert settings["smtp_password"] == "sup3rs3cret"
assert settings["smtp_sender"] == "robot@acme.test"
assert settings["smtp_use_tls"] is False
assert settings["smtp_recipients"] == [
"alerts@acme.test",
"ops@acme.test",
]
assert settings["notify_contact_form"] is True
assert settings["notify_newsletter_signups"] is False
def test_validate_email_settings_detects_invalid_values():
payload = {
"smtp_host": "",
"smtp_port": "not-a-number",
"smtp_sender": "invalid-address",
"smtp_recipients": "good@example.com, bad-address",
"smtp_use_tls": "maybe",
}
errors = email_settings.validate_email_settings(payload)
assert "smtp_host" in errors
assert "smtp_port" in errors
assert "smtp_sender" in errors
assert "smtp_recipients" in errors
assert "smtp_use_tls" in errors
def test_persist_email_settings_serializes_and_updates(monkeypatch):
calls: List[tuple[str, str]] = []
def fake_update(key: str, value: str) -> bool:
calls.append((key, value))
return True
monkeypatch.setattr(email_settings, "update_app_setting", fake_update)
payload = {
"smtp_host": "smtp.acme.test",
"smtp_port": 2525,
"smtp_username": "mailer",
"smtp_password": "password123",
"smtp_sender": "robot@acme.test",
"smtp_use_tls": True,
"smtp_recipients": "alerts@acme.test, ops@acme.test",
"notify_contact_form": False,
"notify_newsletter_signups": True,
}
normalized = email_settings.persist_email_settings(payload)
assert normalized["smtp_port"] == 2525
assert normalized["smtp_use_tls"] is True
assert normalized["smtp_recipients"] == [
"alerts@acme.test",
"ops@acme.test",
]
assert normalized["notify_contact_form"] is False
assert normalized["notify_newsletter_signups"] is True
expected_keys = {
"email_smtp_host",
"email_smtp_port",
"email_smtp_username",
"email_smtp_password",
"email_smtp_sender",
"email_smtp_use_tls",
"email_smtp_recipients",
"email_notify_contact_form",
"email_notify_newsletter_signups",
}
assert {key for key, _ in calls} == expected_keys
assert ("email_smtp_port", "2525") in calls
assert ("email_smtp_use_tls", "true") in calls
assert (
"email_smtp_recipients",
"alerts@acme.test, ops@acme.test",
) in calls
def test_load_effective_smtp_settings_merges_defaults(monkeypatch):
monkeypatch.setattr(
email_settings,
"SMTP_SETTINGS",
{
"host": "fallback.mail",
"port": 465,
"username": "fallback",
"password": "fallback-pass",
"sender": "default@fallback.mail",
"use_tls": True,
"recipients": ["owner@fallback.mail"],
},
raising=False,
)
monkeypatch.setattr(
email_settings,
"load_email_settings",
lambda: {
"smtp_host": "",
"smtp_port": "",
"smtp_username": "",
"smtp_password": "",
"smtp_sender": "",
"smtp_use_tls": False,
"smtp_recipients": "",
"notify_contact_form": True,
"notify_newsletter_signups": False,
},
)
effective = email_settings.load_effective_smtp_settings()
assert effective["host"] == "fallback.mail"
assert effective["port"] == 465
assert effective["username"] == "fallback"
assert effective["password"] == "fallback-pass"
assert effective["sender"] == "default@fallback.mail"
assert effective["use_tls"] is False
assert effective["recipients"] == []
assert effective["notify_contact_form"] is True
assert effective["notify_newsletter_signups"] is False

View File

@@ -1,4 +1,5 @@
import importlib
import pytest
server_app_module = importlib.import_module("server.app")
@@ -11,33 +12,65 @@ def client():
yield client
def test_get_settings_returns_dict(client):
# Login as admin first
client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
resp = client.get('/admin/api/settings')
def login(client):
return client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
def test_email_template_list_requires_auth(client):
resp = client.get('/admin/api/email-templates')
assert resp.status_code == 302
assert resp.headers['Location'] == '/auth/login'
def test_list_email_templates_returns_metadata(client):
login(client)
resp = client.get('/admin/api/email-templates')
assert resp.status_code == 200
body = resp.get_json()
assert body['status'] == 'ok'
assert isinstance(body.get('settings'), dict)
payload = resp.get_json()
assert payload['status'] == 'ok'
assert isinstance(payload['templates'], list)
assert payload['templates'][0]['id'] == 'newsletter_confirmation'
def test_update_and_get_newsletter_template(client):
key = 'newsletter_confirmation_template'
sample = '<p>Thanks for subscribing, {{email}}</p>'
# Update via PUT
# Login as admin first
client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
resp = client.put(f'/admin/api/settings/{key}', json={'value': sample})
def test_get_email_template_returns_content(client):
login(client)
resp = client.get('/admin/api/email-templates/newsletter_confirmation')
assert resp.status_code == 200
body = resp.get_json()
assert body['status'] == 'ok'
# Retrieve via GET and ensure the value is present
resp = client.get('/admin/api/settings')
payload = resp.get_json()
assert payload['status'] == 'ok'
template = payload['template']
assert template['id'] == 'newsletter_confirmation'
assert 'content' in template
def test_update_email_template_persists_content(client):
login(client)
new_content = '<p>Updated template {{email}}</p>'
resp = client.put(
'/admin/api/email-templates/newsletter_confirmation',
json={'content': new_content},
)
assert resp.status_code == 200
body = resp.get_json()
assert body['status'] == 'ok'
settings = body.get('settings') or {}
assert settings.get(key) == sample
payload = resp.get_json()
assert payload['status'] == 'ok'
assert payload['template']['content'] == new_content
# Fetch again to ensure persistence
resp_get = client.get('/admin/api/email-templates/newsletter_confirmation')
assert resp_get.status_code == 200
template = resp_get.get_json()['template']
assert template['content'] == new_content
def test_update_email_template_requires_content(client):
login(client)
resp = client.put(
'/admin/api/email-templates/newsletter_confirmation',
json={'content': ' '},
)
assert resp.status_code == 400
payload = resp.get_json()
assert payload['status'] == 'error'

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Any
import pytest
from server.services import email_templates
def test_list_templates_returns_metadata():
templates = email_templates.list_templates()
assert isinstance(templates, list)
assert templates[0]["id"] == "newsletter_confirmation"
assert "name" in templates[0]
def test_load_template_uses_default_when_not_stored(monkeypatch):
monkeypatch.setattr(email_templates, "get_app_settings", lambda: {})
template = email_templates.load_template("newsletter_confirmation")
assert template["content"] == email_templates.EMAIL_TEMPLATE_DEFINITIONS[
"newsletter_confirmation"
].default_content
def test_persist_template_updates_storage(monkeypatch):
captured: dict[str, Any] = {}
def fake_update(key: str, value: str) -> None:
captured["key"] = key
captured["value"] = value
# Return content via load call after persist
monkeypatch.setattr(email_templates, "update_app_setting", fake_update)
monkeypatch.setattr(
email_templates,
"get_app_settings",
lambda: {"newsletter_confirmation_template": "stored"},
)
updated = email_templates.persist_template(
"newsletter_confirmation", " stored ")
assert captured["key"] == "newsletter_confirmation_template"
assert captured["value"] == "stored"
assert updated["content"] == "stored"