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

@@ -6,6 +6,17 @@ import logging
from .. import auth, settings
from ..database import delete_app_setting, get_app_settings, get_subscribers, update_app_setting
from ..services.email_settings import (
EMAIL_SETTINGS_DEFINITIONS,
load_email_settings,
persist_email_settings,
validate_email_settings,
)
from ..services.email_templates import (
list_templates as list_email_templates,
load_template as load_email_template,
persist_template as persist_email_template,
)
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -96,6 +107,13 @@ def email_templates():
return render_template('admin/admin_email_templates.html')
@bp.route('/email-settings')
@auth.login_required
def email_settings_page():
"""Render the dedicated email settings management page."""
return render_template('admin/admin_email_settings.html')
@bp.route("/api/settings", methods=["GET"])
@auth.login_required
def get_settings_api():
@@ -179,6 +197,121 @@ def delete_setting_api(key: str):
return jsonify({"status": "error", "message": "Failed to delete setting."}), 500
@bp.route("/api/email-settings", methods=["GET"])
@auth.login_required
def get_email_settings_api():
"""Return the current email settings with field metadata."""
try:
data = load_email_settings()
return jsonify({
"status": "ok",
"settings": data,
"schema": EMAIL_SETTINGS_DEFINITIONS,
})
except Exception as exc: # pragma: no cover - logged and surfaced to client
logging.exception("Failed to load email settings: %s", exc)
return jsonify({
"status": "error",
"message": "Failed to load email settings."
}), 500
@bp.route("/api/email-settings", methods=["PUT"])
@auth.login_required
def update_email_settings_api():
"""Validate and persist email settings updates."""
try:
payload = request.get_json(silent=True) or {}
errors = validate_email_settings(payload)
if errors:
return jsonify({
"status": "error",
"message": "Email settings validation failed.",
"errors": errors,
}), 400
updated = persist_email_settings(payload)
return jsonify({
"status": "ok",
"message": "Email settings updated successfully.",
"settings": updated,
})
except Exception as exc: # pragma: no cover - logged and surfaced to client
logging.exception("Failed to update email settings: %s", exc)
return jsonify({
"status": "error",
"message": "Failed to update email settings."
}), 500
@bp.route("/api/email-templates", methods=["GET"])
@auth.login_required
def list_email_templates_api():
"""Return metadata for editable email templates."""
try:
templates = list_email_templates()
return jsonify({"status": "ok", "templates": templates})
except Exception as exc: # pragma: no cover
logging.exception("Failed to list email templates: %s", exc)
return jsonify({
"status": "error",
"message": "Failed to load email templates."
}), 500
@bp.route("/api/email-templates/<template_id>", methods=["GET"])
@auth.login_required
def get_email_template_api(template_id: str):
"""Return the requested email template."""
try:
template = load_email_template(template_id)
return jsonify({"status": "ok", "template": template})
except KeyError:
return jsonify({
"status": "error",
"message": "Email template not found."
}), 404
except Exception as exc: # pragma: no cover
logging.exception(
"Failed to load email template %s: %s", template_id, exc)
return jsonify({
"status": "error",
"message": "Failed to load email template."
}), 500
@bp.route("/api/email-templates/<template_id>", methods=["PUT"])
@auth.login_required
def update_email_template_api(template_id: str):
"""Persist updates to an email template."""
try:
payload = request.get_json(silent=True) or {}
content = payload.get("content", "")
if not isinstance(content, str) or not content.strip():
return jsonify({
"status": "error",
"message": "Template content is required.",
}), 400
updated = persist_email_template(template_id, content)
return jsonify({
"status": "ok",
"template": updated,
})
except KeyError:
return jsonify({
"status": "error",
"message": "Email template not found."
}), 404
except Exception as exc: # pragma: no cover
logging.exception(
"Failed to update email template %s: %s", template_id, exc)
return jsonify({
"status": "error",
"message": "Failed to update email template."
}), 500
@bp.route("/api/newsletter", methods=["GET"])
@auth.login_required
def get_subscribers_api():

View File

@@ -6,12 +6,12 @@ import smtplib
from dataclasses import dataclass
from datetime import datetime, timezone
from email.message import EmailMessage
from typing import Any, Dict, Tuple
from typing import Any, Dict, Tuple, cast
from .. import settings
from ..database import save_contact
from ..metrics import record_submission
from ..utils import is_valid_email
from .email_settings import load_effective_smtp_settings
@dataclass
@@ -70,12 +70,22 @@ def persist_submission(submission: ContactSubmission) -> int:
def send_notification(submission: ContactSubmission) -> bool:
"""Send an email notification for the submission if SMTP is configured."""
if not settings.SMTP_SETTINGS["host"] or not settings.SMTP_SETTINGS["recipients"]:
smtp_config = load_effective_smtp_settings()
if not smtp_config.get("notify_contact_form"):
logging.info("Contact notifications disabled; skipping email dispatch")
return False
if not smtp_config.get("host") or not smtp_config.get("recipients"):
logging.info("SMTP not configured; skipping email notification")
return False
sender = settings.SMTP_SETTINGS["sender"] or "no-reply@example.com"
recipients = settings.SMTP_SETTINGS["recipients"]
sender = smtp_config.get("sender") or "no-reply@example.com"
recipients = smtp_config.get("recipients", [])
host = cast(str, smtp_config.get("host"))
port = int(smtp_config.get("port") or 0)
username = smtp_config.get("username") or None
password = smtp_config.get("password") or ""
msg = EmailMessage()
msg["Subject"] = f"Neue Kontaktanfrage von {submission.name}"
@@ -98,12 +108,11 @@ def send_notification(submission: ContactSubmission) -> bool:
)
try:
with smtplib.SMTP(settings.SMTP_SETTINGS["host"], settings.SMTP_SETTINGS["port"], timeout=15) as server:
if settings.SMTP_SETTINGS["use_tls"]:
with smtplib.SMTP(host, port, timeout=15) as server:
if smtp_config.get("use_tls"):
server.starttls()
if settings.SMTP_SETTINGS["username"]:
server.login(
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"] or "")
if username:
server.login(username, password)
server.send_message(msg)
logging.info("Notification email dispatched to %s", recipients)
return True

View File

@@ -0,0 +1,224 @@
"""Email settings service helpers."""
from __future__ import annotations
from typing import Any, Dict, Tuple
from ..database import get_app_settings, update_app_setting
from ..settings import (
SMTP_SETTINGS,
EMAIL_NOTIFY_CONTACT_FORM,
EMAIL_NOTIFY_NEWSLETTER_SIGNUPS,
)
from ..utils import normalize_recipients, is_valid_email
EMAIL_SETTING_PREFIX = "email_"
# Definition metadata describing each configurable field.
EMAIL_SETTINGS_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"smtp_host": {
"type": "string",
"required": True,
"default": SMTP_SETTINGS.get("host") or "",
"description": "Hostname or IP address of the SMTP server.",
},
"smtp_port": {
"type": "int",
"required": True,
"default": int(SMTP_SETTINGS.get("port") or 587),
"description": "Port number used to connect to the SMTP server.",
},
"smtp_username": {
"type": "string",
"required": False,
"default": SMTP_SETTINGS.get("username") or "",
"description": "Username for authenticating with the SMTP server (optional).",
},
"smtp_password": {
"type": "string",
"required": False,
"default": SMTP_SETTINGS.get("password") or "",
"description": "Password for authenticating with the SMTP server (optional).",
},
"smtp_sender": {
"type": "string",
"required": True,
"default": SMTP_SETTINGS.get("sender") or SMTP_SETTINGS.get("username") or "",
"description": "Default email address used in the From header when sending mail.",
},
"smtp_use_tls": {
"type": "bool",
"required": True,
"default": bool(SMTP_SETTINGS.get("use_tls", True)),
"description": "Enable TLS when communicating with the SMTP server.",
},
"smtp_recipients": {
"type": "list",
"required": False,
"default": SMTP_SETTINGS.get("recipients") or [],
"description": "Comma separated list of notification recipient emails.",
},
"notify_contact_form": {
"type": "bool",
"required": True,
"default": EMAIL_NOTIFY_CONTACT_FORM,
"description": "Send notification emails for new contact form submissions.",
},
"notify_newsletter_signups": {
"type": "bool",
"required": True,
"default": EMAIL_NOTIFY_NEWSLETTER_SIGNUPS,
"description": "Send notification emails for new newsletter subscriber signups.",
},
}
def _storage_key(field: str) -> str:
"""Return the persistent storage key for a given field."""
return f"{EMAIL_SETTING_PREFIX}{field}"
def _ensure_bool(value: Any) -> bool:
"""Coerce user-supplied values into boolean form."""
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _ensure_int(value: Any) -> Tuple[bool, int | None]:
"""Try to coerce a value into an integer."""
try:
return True, int(value)
except (TypeError, ValueError):
return False, None
def _serialize(field: str, value: Any) -> str:
"""Serialize a typed value to its string representation for storage."""
definition = EMAIL_SETTINGS_DEFINITIONS[field]
if definition["type"] == "bool":
return "true" if bool(value) else "false"
if definition["type"] == "int":
return str(int(value))
if definition["type"] == "list":
if isinstance(value, str):
value = normalize_recipients(value)
return ", ".join(value)
return str(value or "")
def _deserialize(field: str, raw_value: Any) -> Any:
"""Deserialize the stored string representation for a field."""
definition = EMAIL_SETTINGS_DEFINITIONS[field]
if raw_value is None or raw_value == "":
return definition["default"]
if definition["type"] == "bool":
return _ensure_bool(raw_value)
if definition["type"] == "int":
ok, coerced = _ensure_int(raw_value)
return coerced if ok and coerced is not None else definition["default"]
if definition["type"] == "list":
if isinstance(raw_value, (list, tuple)):
return [item for item in raw_value if item]
return normalize_recipients(str(raw_value))
return str(raw_value)
def load_email_settings() -> Dict[str, Any]:
"""Load email settings, combining stored overrides with defaults."""
stored = get_app_settings()
results: Dict[str, Any] = {}
for field in EMAIL_SETTINGS_DEFINITIONS:
raw_value = stored.get(_storage_key(field))
results[field] = _deserialize(field, raw_value)
return results
def load_effective_smtp_settings() -> Dict[str, Any]:
"""Return SMTP configuration merging persisted settings with environment defaults."""
effective = load_email_settings()
recipients_value = effective.get("smtp_recipients")
if isinstance(recipients_value, list):
effective_recipients = [item for item in recipients_value if item]
else:
effective_recipients = normalize_recipients(str(recipients_value or ""))
return {
"host": effective.get("smtp_host") or SMTP_SETTINGS.get("host"),
"port": effective.get("smtp_port") or SMTP_SETTINGS.get("port"),
"username": effective.get("smtp_username") or SMTP_SETTINGS.get("username"),
"password": effective.get("smtp_password") or SMTP_SETTINGS.get("password"),
"sender": effective.get("smtp_sender") or SMTP_SETTINGS.get("sender") or SMTP_SETTINGS.get("username"),
"use_tls": bool(effective.get("smtp_use_tls")) if effective.get("smtp_use_tls") is not None else SMTP_SETTINGS.get("use_tls", True),
"recipients": effective_recipients,
"notify_contact_form": bool(effective.get("notify_contact_form")),
"notify_newsletter_signups": bool(effective.get("notify_newsletter_signups")),
}
def validate_email_settings(payload: Dict[str, Any]) -> Dict[str, str]:
"""Validate user supplied email settings. Returns mapping of field -> error."""
errors: Dict[str, str] = {}
smtp_host = str(payload.get("smtp_host", "")).strip()
if not smtp_host:
errors["smtp_host"] = "SMTP host is required."
ok, port_value = _ensure_int(payload.get("smtp_port"))
if not ok or port_value is None or not (1 <= port_value <= 65535):
errors["smtp_port"] = "SMTP port must be a valid integer between 1 and 65535."
sender = str(payload.get("smtp_sender", "")).strip()
if not sender:
errors["smtp_sender"] = "Sender email is required."
elif not is_valid_email(sender):
errors["smtp_sender"] = "Sender must be a valid email address."
recipients_raw = payload.get("smtp_recipients")
if isinstance(recipients_raw, list):
recipient_list = [item.strip() for item in recipients_raw if item]
else:
recipient_list = normalize_recipients(str(recipients_raw or ""))
if recipient_list:
invalid = [addr for addr in recipient_list if not is_valid_email(addr)]
if invalid:
errors["smtp_recipients"] = "Invalid recipient email(s): " + ", ".join(
invalid)
for flag_field in ("smtp_use_tls", "notify_contact_form", "notify_newsletter_signups"):
value = payload.get(flag_field)
if value is None:
continue
# Ensure value is interpretable as boolean.
if not isinstance(value, bool) and str(value).strip().lower() not in {"1", "0", "true", "false", "yes", "no", "on", "off"}:
errors[flag_field] = "Provide a boolean value."
return errors
def persist_email_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Persist validated email settings and return the normalized payload."""
normalized: Dict[str, Any] = {}
for field, meta in EMAIL_SETTINGS_DEFINITIONS.items():
value = payload.get(field, meta["default"])
if meta["type"] == "bool":
value = _ensure_bool(value)
elif meta["type"] == "int":
ok, coerced = _ensure_int(value)
value = coerced if ok and coerced is not None else meta["default"]
elif meta["type"] == "list":
if isinstance(value, list):
value = [item.strip() for item in value if item]
else:
value = normalize_recipients(str(value or ""))
else:
value = str(value or "").strip()
# Persist using string representation
update_app_setting(_storage_key(field), _serialize(field, value))
normalized[field] = value
return normalized

View File

@@ -0,0 +1,86 @@
"""Email template management helpers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List
from ..database import get_app_settings, update_app_setting
from .. import settings
@dataclass(frozen=True)
class EmailTemplateDefinition:
"""Definition metadata describing an editable email template."""
id: str
setting_key: str
name: str
description: str
default_content: str
EMAIL_TEMPLATE_DEFINITIONS: Dict[str, EmailTemplateDefinition] = {
"newsletter_confirmation": EmailTemplateDefinition(
id="newsletter_confirmation",
setting_key="newsletter_confirmation_template",
name="Newsletter Confirmation",
description="HTML email sent to subscribers immediately after they confirm their newsletter signup.",
default_content=getattr(
settings, "NEWSLETTER_CONFIRMATION_TEMPLATE", "").strip(),
),
}
def list_templates() -> List[dict]:
"""Return a list of metadata describing available email templates."""
return [
{
"id": definition.id,
"name": definition.name,
"description": definition.description,
}
for definition in EMAIL_TEMPLATE_DEFINITIONS.values()
]
def _load_stored_templates() -> dict:
"""Return stored template values keyed by their setting key."""
return get_app_settings()
def load_template(template_id: str) -> dict:
"""Return template metadata and content for the requested template."""
if template_id not in EMAIL_TEMPLATE_DEFINITIONS:
raise KeyError(template_id)
definition = EMAIL_TEMPLATE_DEFINITIONS[template_id]
stored = _load_stored_templates()
content = stored.get(definition.setting_key, definition.default_content)
return {
"id": definition.id,
"name": definition.name,
"description": definition.description,
"content": content,
}
def persist_template(template_id: str, content: str) -> dict:
"""Persist template content and return the updated template payload."""
if template_id not in EMAIL_TEMPLATE_DEFINITIONS:
raise KeyError(template_id)
definition = EMAIL_TEMPLATE_DEFINITIONS[template_id]
content = (content or "").strip()
update_app_setting(definition.setting_key, content)
return load_template(template_id)
def get_template_content(template_id: str) -> str:
"""Return just the template body, falling back to defaults when unset."""
if template_id not in EMAIL_TEMPLATE_DEFINITIONS:
raise KeyError(template_id)
definition = EMAIL_TEMPLATE_DEFINITIONS[template_id]
stored = _load_stored_templates()
return stored.get(definition.setting_key, definition.default_content)

View File

@@ -2,9 +2,12 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from ..database import save_subscriber, delete_subscriber, update_subscriber
from ..utils import is_valid_email
from .email_settings import load_effective_smtp_settings
from .email_templates import get_template_content
def validate_email(email: str) -> bool:
@@ -31,30 +34,31 @@ def update_email(old_email: str, new_email: str) -> bool:
def send_subscription_confirmation(email: str) -> bool:
"""Send a confirmation email to the subscriber."""
import logging
from .. import settings
if not settings.SMTP_SETTINGS["host"]:
smtp_config = load_effective_smtp_settings()
if not smtp_config.get("notify_newsletter_signups"):
logging.info(
"Newsletter signup notifications disabled; skipping email")
return False
host = smtp_config.get("host")
port = int(smtp_config.get("port") or 0)
username = smtp_config.get("username")
password = smtp_config.get("password") or ""
if not host:
logging.info("SMTP not configured; skipping confirmation email")
return False
# Get template from settings or default
template = getattr(settings, 'NEWSLETTER_CONFIRMATION_TEMPLATE', """
<html>
<body>
<h2>Welcome to our Newsletter!</h2>
<p>Thank you for subscribing to our newsletter. You're now part of our community and will receive updates on our latest news and offers.</p>
<p>If you wish to unsubscribe at any time, you can do so by visiting our <a href="https://your-domain.com/newsletter/manage">subscription management page</a>.</p>
<p>Best regards,<br>The Team</p>
</body>
</html>
""").strip()
# Get template content (persisted or default)
template = get_template_content("newsletter_confirmation")
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
sender = settings.SMTP_SETTINGS["sender"] or "noreply@example.com"
sender = smtp_config.get("sender") or "noreply@example.com"
# Create message
msg = MIMEMultipart('alternative')
@@ -67,28 +71,33 @@ def send_subscription_confirmation(email: str) -> bool:
msg.attach(html_part)
# Send email
with smtplib.SMTP(settings.SMTP_SETTINGS["host"], settings.SMTP_SETTINGS["port"], timeout=15) as server:
if settings.SMTP_SETTINGS["use_tls"]:
with smtplib.SMTP(cast(str, host), port, timeout=15) as server:
if smtp_config.get("use_tls"):
server.starttls()
if settings.SMTP_SETTINGS["username"]:
server.login(
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"] or "")
if username:
server.login(username, password)
server.send_message(msg)
logging.info("Confirmation email sent to %s", email)
return True
except Exception as exc:
logging.exception("Failed to send confirmation email to %s: %s", email, exc)
logging.exception(
"Failed to send confirmation email to %s: %s", email, exc)
return False
def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str], sender_name: str | None = None) -> int:
"""Send newsletter to list of email addresses. Returns count of successful sends."""
import logging
from .. import settings
smtp_config = load_effective_smtp_settings()
if not settings.SMTP_SETTINGS["host"]:
host = smtp_config.get("host")
port = int(smtp_config.get("port") or 0)
username = smtp_config.get("username")
password = smtp_config.get("password")
if not host:
logging.error("SMTP not configured, cannot send newsletter")
return 0
@@ -100,7 +109,7 @@ def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str]
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = settings.SMTP_SETTINGS["sender"] or "noreply@example.com"
msg['From'] = smtp_config.get("sender") or "noreply@example.com"
# Format content
formatted_content = content.replace('\n', '<br>')
@@ -118,13 +127,12 @@ def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str]
# Send to each recipient individually for better deliverability
success_count = 0
with smtplib.SMTP(settings.SMTP_SETTINGS["host"], settings.SMTP_SETTINGS["port"]) as server:
if settings.SMTP_SETTINGS["use_tls"]:
with smtplib.SMTP(cast(str, host), port) as server:
if smtp_config.get("use_tls"):
server.starttls()
if settings.SMTP_SETTINGS["username"] and settings.SMTP_SETTINGS["password"]:
server.login(
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"])
if username and password is not None:
server.login(username, password)
for email in emails:
try:

View File

@@ -58,6 +58,14 @@ SMTP_SETTINGS = {
"recipients": normalize_recipients(os.getenv("SMTP_RECIPIENTS")),
}
EMAIL_NOTIFY_CONTACT_FORM = os.getenv("EMAIL_NOTIFY_CONTACT_FORM", "true").lower() in {
"1", "true", "yes"}
EMAIL_NOTIFY_NEWSLETTER_SIGNUPS = os.getenv("EMAIL_NOTIFY_NEWSLETTER_SIGNUPS", "false").lower() in {
"1", "true", "yes"}
SMTP_SETTINGS["notify_contact_form"] = EMAIL_NOTIFY_CONTACT_FORM
SMTP_SETTINGS["notify_newsletter_signups"] = EMAIL_NOTIFY_NEWSLETTER_SIGNUPS
if not SMTP_SETTINGS["sender"] and SMTP_SETTINGS["username"]:
SMTP_SETTINGS["sender"] = SMTP_SETTINGS["username"]