feat: Add email settings management and templates functionality
- 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:
@@ -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
|
||||
|
||||
101
tests/test_admin_email_settings_api.py
Normal file
101
tests/test_admin_email_settings_api.py
Normal 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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
167
tests/test_email_settings_service.py
Normal file
167
tests/test_email_settings_service.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
44
tests/test_email_templates_service.py
Normal file
44
tests/test_email_templates_service.py
Normal 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"
|
||||
Reference in New Issue
Block a user