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:
@@ -17,6 +17,9 @@ SMTP_PASSWORD=your-password
|
||||
SMTP_SENDER=web@example.com
|
||||
SMTP_RECIPIENTS=team@example.com
|
||||
SMTP_USE_TLS=true
|
||||
# Default notification toggles (admin UI can override these at runtime)
|
||||
EMAIL_NOTIFY_CONTACT_FORM=true
|
||||
EMAIL_NOTIFY_NEWSLETTER_SIGNUPS=false
|
||||
# Set to 1 to enable SMTP integration tests during CI/CD (requires valid SMTP settings)
|
||||
RUN_SMTP_INTEGRATION_TEST=0
|
||||
|
||||
|
||||
14
README.md
14
README.md
@@ -28,6 +28,7 @@ Backend service for a static website. The app accepts contact and newsletter sub
|
||||
- [Health Checks and Monitoring](#health-checks-and-monitoring)
|
||||
- [Testing](#testing)
|
||||
- [Deployment Notes](#deployment-notes)
|
||||
- [Admin Email Settings](#admin-email-settings)
|
||||
- [Email Templates](#email-templates)
|
||||
|
||||
## Overview
|
||||
@@ -209,7 +210,7 @@ When `POSTGRES_URL` is set and `psycopg2-binary` is available, the app prefers P
|
||||
### Email delivery
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------- | ----------------------------------------------------------------------- | -------- |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||
| `SMTP_HOST` | SMTP server hostname or IP. Leave empty to disable email notifications. | _(none)_ |
|
||||
| `SMTP_PORT` | SMTP server port. | `587` |
|
||||
| `SMTP_USERNAME` | Username for SMTP authentication. | _(none)_ |
|
||||
@@ -217,6 +218,8 @@ When `POSTGRES_URL` is set and `psycopg2-binary` is available, the app prefers P
|
||||
| `SMTP_SENDER` | Sender email address; defaults to `SMTP_USERNAME` when unset. | _(none)_ |
|
||||
| `SMTP_RECIPIENTS` | Comma-separated recipient list for notifications. | _(none)_ |
|
||||
| `SMTP_USE_TLS` | Enables STARTTLS when `true`. | `true` |
|
||||
| `EMAIL_NOTIFY_CONTACT_FORM` | Default toggle for sending emails when new contact submissions arrive. Can be overridden in the admin UI. | `true` |
|
||||
| `EMAIL_NOTIFY_NEWSLETTER_SIGNUPS` | Default toggle for sending notification/confirmation emails when new newsletter signups occur. Admin UI can override per deployment. | `false` |
|
||||
|
||||
### Rate limiting and caching
|
||||
|
||||
@@ -268,9 +271,16 @@ SMTP integration tests are skipped unless `RUN_SMTP_INTEGRATION_TEST=1` and vali
|
||||
- Import or mirror the required reusable actions (`actions/checkout`, `actions/setup-python`, and the Docker actions) into your Gitea instance so that the workflow can resolve them.
|
||||
- For production use, deploy the container behind a load balancer or reverse proxy and supply the appropriate environment variables.
|
||||
|
||||
## Admin Email Settings
|
||||
|
||||
- Navigate to `/admin/email-settings` after authenticating to manage SMTP host, credentials, sender identity, notification recipients, and feature toggles.
|
||||
- Values saved through the form are persisted in the `app_settings` table and override any `.env` defaults until updated again.
|
||||
- The page surfaces validation feedback in-line and falls back to environment-sourced defaults when a field is left blank.
|
||||
- Toggle `Email notifications for new contact submissions` and `Confirmation emails for newsletter signups` to control runtime behaviour. These switches seed from `EMAIL_NOTIFY_CONTACT_FORM` and `EMAIL_NOTIFY_NEWSLETTER_SIGNUPS` respectively.
|
||||
|
||||
## Email Templates
|
||||
|
||||
The application supports customizable email templates for newsletter confirmations. The confirmation email template can be edited through the admin settings page under the dynamic settings management. The template uses HTML format and should include an unsubscribe link for compliance with email regulations.
|
||||
The application supports customizable email templates for newsletter confirmations. Administrators can manage templates from the dedicated **Email Templates** page (`/admin/email-templates`), which provides a list/detail editor for each available template. Changes are persisted to dynamic settings, allowing runtime updates without redeploying the application. Templates use HTML format and should include an unsubscribe link for compliance with email regulations.
|
||||
|
||||
Default confirmation email includes:
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
224
server/services/email_settings.py
Normal file
224
server/services/email_settings.py
Normal 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
|
||||
86
server/services/email_templates.py
Normal file
86
server/services/email_templates.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ nav a:hover {
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.nav a.active {
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
@@ -172,10 +176,24 @@ nav a:hover {
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-error {
|
||||
border-color: #dc3545;
|
||||
}
|
||||
.form-group textarea {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.checkbox-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
@@ -277,3 +295,94 @@ nav a:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Email templates layout */
|
||||
.email-templates-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.email-templates-list {
|
||||
flex: 0 0 280px;
|
||||
max-width: 320px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #f8f9fa;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.template-item:hover {
|
||||
border-color: #007bff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.template-item.active {
|
||||
border-color: #007bff;
|
||||
background: #e2ecff;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
margin: 0 0 12px 0;
|
||||
color: #555555;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.email-template-editor {
|
||||
flex: 1 1 380px;
|
||||
min-width: 300px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.email-template-editor .form-group textarea {
|
||||
min-height: 360px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0;
|
||||
color: #555555;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.email-templates-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.email-templates-list {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.email-template-editor {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,162 @@ async function saveEmbedSetting(key, inputId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EMAIL SETTINGS ====================
|
||||
|
||||
const emailSettingsFieldConfig = {
|
||||
smtp_host: { selector: "#smtpHost", type: "text" },
|
||||
smtp_port: { selector: "#smtpPort", type: "number" },
|
||||
smtp_username: { selector: "#smtpUsername", type: "text" },
|
||||
smtp_password: { selector: "#smtpPassword", type: "text" },
|
||||
smtp_sender: { selector: "#smtpSender", type: "text" },
|
||||
smtp_recipients: { selector: "#smtpRecipients", type: "textarea" },
|
||||
smtp_use_tls: { selector: "#smtpUseTls", type: "checkbox" },
|
||||
notify_contact_form: { selector: "#notifyContactForm", type: "checkbox" },
|
||||
notify_newsletter_signups: {
|
||||
selector: "#notifyNewsletter",
|
||||
type: "checkbox",
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeRecipientsInput(value) {
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
if (!value) return "";
|
||||
return value;
|
||||
}
|
||||
|
||||
function applyEmailSettingsForm(settings) {
|
||||
Object.entries(emailSettingsFieldConfig).forEach(([field, config]) => {
|
||||
const element = document.querySelector(config.selector);
|
||||
if (!element) return;
|
||||
|
||||
const fieldValue = settings[field];
|
||||
switch (config.type) {
|
||||
case "checkbox": {
|
||||
const normalized =
|
||||
fieldValue === true ||
|
||||
fieldValue === "true" ||
|
||||
fieldValue === 1 ||
|
||||
fieldValue === "1";
|
||||
element.checked = normalized;
|
||||
break;
|
||||
}
|
||||
case "number": {
|
||||
if (typeof fieldValue === "number" && Number.isFinite(fieldValue)) {
|
||||
element.value = fieldValue;
|
||||
} else {
|
||||
const parsed = parseInt(fieldValue, 10);
|
||||
element.value = Number.isFinite(parsed) ? parsed : "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "textarea":
|
||||
element.value = normalizeRecipientsInput(fieldValue);
|
||||
break;
|
||||
default:
|
||||
element.value = fieldValue || "";
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectEmailSettingsForm() {
|
||||
const payload = {};
|
||||
Object.entries(emailSettingsFieldConfig).forEach(([field, config]) => {
|
||||
const element = document.querySelector(config.selector);
|
||||
if (!element) return;
|
||||
|
||||
switch (config.type) {
|
||||
case "checkbox":
|
||||
payload[field] = element.checked;
|
||||
break;
|
||||
case "number":
|
||||
if (element.value === "") {
|
||||
payload[field] = "";
|
||||
} else {
|
||||
const parsed = parseInt(element.value, 10);
|
||||
payload[field] = Number.isFinite(parsed) ? parsed : element.value;
|
||||
}
|
||||
break;
|
||||
case "textarea":
|
||||
payload[field] = element.value;
|
||||
break;
|
||||
default:
|
||||
payload[field] = element.value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function clearEmailFieldErrors() {
|
||||
Object.values(emailSettingsFieldConfig).forEach((config) => {
|
||||
const element = document.querySelector(config.selector);
|
||||
if (element) element.classList.remove("input-error");
|
||||
});
|
||||
}
|
||||
|
||||
function applyEmailFieldErrors(errors) {
|
||||
Object.entries(errors || {}).forEach(([field]) => {
|
||||
const config = emailSettingsFieldConfig[field];
|
||||
if (!config) return;
|
||||
const element = document.querySelector(config.selector);
|
||||
if (element) element.classList.add("input-error");
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEmailSettings() {
|
||||
try {
|
||||
const response = await fetch("/admin/api/email-settings");
|
||||
const data = await response.json();
|
||||
if (data.status === "ok" && data.settings) {
|
||||
applyEmailSettingsForm(data.settings);
|
||||
} else {
|
||||
showMessage(data.message || "Failed to load email settings", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load email settings", error);
|
||||
showMessage("Failed to load email settings", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEmailSettings(event) {
|
||||
event.preventDefault();
|
||||
clearEmailFieldErrors();
|
||||
|
||||
const payload = collectEmailSettingsForm();
|
||||
|
||||
try {
|
||||
const response = await fetch("/admin/api/email-settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.status === "ok") {
|
||||
showMessage("Email settings updated successfully.", "success");
|
||||
applyEmailSettingsForm(data.settings || {});
|
||||
} else {
|
||||
applyEmailFieldErrors(data.errors);
|
||||
const errorSummary = Array.isArray(data.errors)
|
||||
? data.errors.join(" ")
|
||||
: data && data.errors
|
||||
? Object.values(data.errors).join(" ")
|
||||
: "";
|
||||
const message = [
|
||||
data.message || "Failed to update email settings",
|
||||
errorSummary,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
showMessage(message, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update email settings", error);
|
||||
showMessage("Failed to update email settings", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EMBED MANAGEMENT ====================
|
||||
|
||||
/**
|
||||
@@ -280,56 +436,170 @@ async function loadDashboardStats() {
|
||||
|
||||
// ==================== EMAIL TEMPLATES ====================
|
||||
|
||||
/**
|
||||
* Loads email template from settings
|
||||
*/
|
||||
function loadEmailTemplate() {
|
||||
const textarea = document.getElementById("newsletterTemplate");
|
||||
let emailTemplatesCache = [];
|
||||
let activeEmailTemplateId = null;
|
||||
|
||||
function setEmailTemplateMessage(text, type = "info") {
|
||||
const messageEl = document.getElementById("templateMessage");
|
||||
if (!messageEl) return;
|
||||
|
||||
if (!text) {
|
||||
messageEl.textContent = "";
|
||||
messageEl.className = "message";
|
||||
messageEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = `message ${type}`;
|
||||
messageEl.style.display = "block";
|
||||
}
|
||||
|
||||
function highlightSelectedTemplate(templateId) {
|
||||
const buttons = document.querySelectorAll(
|
||||
"#emailTemplatesList button[data-template-id]"
|
||||
);
|
||||
buttons.forEach((button) => {
|
||||
if (button.dataset.templateId === templateId) {
|
||||
button.classList.add("active");
|
||||
} else {
|
||||
button.classList.remove("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderEmailTemplatesList(templates) {
|
||||
const listEl = document.getElementById("emailTemplatesList");
|
||||
if (!listEl) return;
|
||||
|
||||
if (!templates.length) {
|
||||
listEl.innerHTML =
|
||||
'<p class="empty-state">No editable templates are configured.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = templates
|
||||
.map(
|
||||
(template) => `
|
||||
<button type="button" class="template-item" data-template-id="${
|
||||
template.id
|
||||
}">
|
||||
<span class="template-name">${escapeHtml(template.name)}</span>
|
||||
<span class="template-description">${escapeHtml(
|
||||
template.description
|
||||
)}</span>
|
||||
</button>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll("button[data-template-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
selectEmailTemplate(button.dataset.templateId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function disableEmailTemplateEditor(disabled) {
|
||||
const form = document.getElementById("emailTemplateForm");
|
||||
const textarea = document.getElementById("templateContent");
|
||||
const saveButton = document.getElementById("saveTemplateButton");
|
||||
|
||||
if (form) form.classList.toggle("disabled", disabled);
|
||||
if (textarea) textarea.disabled = disabled;
|
||||
if (saveButton) saveButton.disabled = disabled;
|
||||
}
|
||||
|
||||
async function loadEmailTemplatesPage() {
|
||||
setEmailTemplateMessage("Loading email templates...", "info");
|
||||
disableEmailTemplateEditor(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/admin/api/email-templates");
|
||||
const data = await response.json();
|
||||
if (data.status !== "ok") {
|
||||
throw new Error(data.message || "Failed to load email templates");
|
||||
}
|
||||
|
||||
emailTemplatesCache = Array.isArray(data.templates) ? data.templates : [];
|
||||
renderEmailTemplatesList(emailTemplatesCache);
|
||||
setEmailTemplateMessage("");
|
||||
|
||||
if (emailTemplatesCache.length) {
|
||||
selectEmailTemplate(emailTemplatesCache[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load email templates", error);
|
||||
setEmailTemplateMessage("Failed to load email templates", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function selectEmailTemplate(templateId) {
|
||||
if (!templateId) return;
|
||||
|
||||
setEmailTemplateMessage("Loading template...", "info");
|
||||
disableEmailTemplateEditor(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/email-templates/${templateId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== "ok" || !data.template) {
|
||||
throw new Error(data.message || "Failed to load template");
|
||||
}
|
||||
|
||||
const { name, description, content, id } = data.template;
|
||||
const titleEl = document.getElementById("templateTitle");
|
||||
const descriptionEl = document.getElementById("templateDescription");
|
||||
const textarea = document.getElementById("templateContent");
|
||||
|
||||
if (titleEl) titleEl.textContent = name;
|
||||
if (descriptionEl) descriptionEl.textContent = description;
|
||||
if (textarea) textarea.value = content || "";
|
||||
|
||||
activeEmailTemplateId = id;
|
||||
highlightSelectedTemplate(id);
|
||||
setEmailTemplateMessage("");
|
||||
disableEmailTemplateEditor(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to load template", error);
|
||||
setEmailTemplateMessage("Failed to load template", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEmailTemplate(event) {
|
||||
if (event) event.preventDefault();
|
||||
if (!activeEmailTemplateId) return;
|
||||
|
||||
const textarea = document.getElementById("templateContent");
|
||||
if (!textarea) return;
|
||||
|
||||
fetch("/admin/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (
|
||||
data.status === "ok" &&
|
||||
data.settings &&
|
||||
data.settings.newsletter_confirmation_template
|
||||
) {
|
||||
textarea.value = data.settings.newsletter_confirmation_template;
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to load template", err));
|
||||
}
|
||||
const content = textarea.value || "";
|
||||
setEmailTemplateMessage("Saving template...", "info");
|
||||
disableEmailTemplateEditor(true);
|
||||
|
||||
/**
|
||||
* Saves email template to settings
|
||||
*/
|
||||
function saveEmailTemplate() {
|
||||
const textarea = document.getElementById("newsletterTemplate");
|
||||
const message = document.getElementById("message");
|
||||
if (!textarea || !message) return;
|
||||
|
||||
const value = textarea.value || "";
|
||||
|
||||
fetch("/admin/api/settings/newsletter_confirmation_template", {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/admin/api/email-templates/${activeEmailTemplateId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const isSuccess = data.status === "ok";
|
||||
message.className = `message ${isSuccess ? "success" : "error"}`;
|
||||
message.textContent = isSuccess
|
||||
? "Template saved"
|
||||
: `Failed to save template: ${data.message || ""}`;
|
||||
setTimeout(() => (message.textContent = ""), 4000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to save template", err);
|
||||
message.className = "message error";
|
||||
message.textContent = "Failed to save template";
|
||||
});
|
||||
body: JSON.stringify({ content }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.status === "ok") {
|
||||
setEmailTemplateMessage("Template saved successfully.", "success");
|
||||
disableEmailTemplateEditor(false);
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to save template");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save template", error);
|
||||
setEmailTemplateMessage("Failed to save template", "error");
|
||||
disableEmailTemplateEditor(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== NEWSLETTER CREATION ====================
|
||||
@@ -890,8 +1160,11 @@ window.admin = {
|
||||
copyNewsletterIframeCode,
|
||||
loadEmbedSettingsAndInit,
|
||||
loadDashboardStats,
|
||||
loadEmailTemplate,
|
||||
loadEmailTemplatesPage,
|
||||
selectEmailTemplate,
|
||||
saveEmailTemplate,
|
||||
loadEmailSettings,
|
||||
submitEmailSettings,
|
||||
loadNewsletterStats,
|
||||
previewNewsletter,
|
||||
saveDraft,
|
||||
@@ -928,13 +1201,14 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
|
||||
// Email templates
|
||||
if (document.getElementById("newsletterTemplate")) {
|
||||
loadEmailTemplate();
|
||||
const form = document.getElementById("templateForm");
|
||||
const emailTemplatesPage = document.getElementById("emailTemplatesPage");
|
||||
if (emailTemplatesPage) {
|
||||
loadEmailTemplatesPage();
|
||||
const form = document.getElementById("emailTemplateForm");
|
||||
if (form) {
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
saveEmailTemplate();
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
saveEmailTemplate(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -983,4 +1257,11 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (document.getElementById("settingsList")) {
|
||||
loadSettingsForList();
|
||||
}
|
||||
|
||||
// Email settings
|
||||
const emailSettingsForm = document.getElementById("emailSettingsForm");
|
||||
if (emailSettingsForm) {
|
||||
loadEmailSettings();
|
||||
emailSettingsForm.addEventListener("submit", submitEmailSettings);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,53 +1,43 @@
|
||||
<nav class="nav">
|
||||
<!--
|
||||
admin_dashboard.html
|
||||
admin_newsletter.html
|
||||
admin_newsletter_create.html
|
||||
admin_settings.html
|
||||
admin_submissions.html
|
||||
admin_embeds.html
|
||||
|
||||
newsletter_manage.html
|
||||
unsubscribe_confirmation.html
|
||||
-->
|
||||
<a
|
||||
href="/admin/"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
href="{{ url_for('admin.dashboard') }}"
|
||||
class="nav-link{% if request.path in ['/admin', '/admin/'] %} active{% endif %}"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/admin/submissions"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
href="{{ url_for('admin.submissions') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/submissions') %} active{% endif %}"
|
||||
>Contact Submissions</a
|
||||
>
|
||||
<a
|
||||
href="/admin/newsletter"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
href="{{ url_for('admin.newsletter_subscribers') }}"
|
||||
class="nav-link{% if request.path == '/admin/newsletter' %} active{% endif %}"
|
||||
>Subscribers</a
|
||||
>
|
||||
<a
|
||||
href="/admin/newsletter/create"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
href="{{ url_for('admin.newsletter_create') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/newsletter/create') %} active{% endif %}"
|
||||
>Create Newsletter</a
|
||||
>
|
||||
<a
|
||||
href="/admin/embeds"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
href="{{ url_for('admin.embeds') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/embeds') %} active{% endif %}"
|
||||
>Embeds</a
|
||||
>
|
||||
<a
|
||||
href="/admin/email-templates"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
href="{{ url_for('admin.email_settings_page') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/email-settings') %} active{% endif %}"
|
||||
>Email Settings</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.email_templates') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/email-templates') %} active{% endif %}"
|
||||
>Email Templates</a
|
||||
>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
href="{{ url_for('admin.settings_page') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/settings') and not request.path.startswith('/admin/email-settings') %} active{% endif %}"
|
||||
>Settings</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('auth.logout') }}"
|
||||
style="color: #007bff; text-decoration: none"
|
||||
>Logout</a
|
||||
>
|
||||
<a href="{{ url_for('auth.logout') }}" class="nav-link">Logout</a>
|
||||
</nav>
|
||||
|
||||
81
templates/admin/admin_email_settings.html
Normal file
81
templates/admin/admin_email_settings.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends "_base.html" %}
|
||||
{% block title %}Email Settings{% endblock %}
|
||||
{% block heading %}Email Notification Settings{% endblock %}
|
||||
{% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-section">
|
||||
<h2>SMTP Configuration</h2>
|
||||
<p>
|
||||
Adjust the SMTP server configuration and notification preferences used for
|
||||
contact form alerts and newsletter messaging. Values saved here override the
|
||||
environment defaults documented in <code>.env</code>.
|
||||
</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<form id="emailSettingsForm" class="email-settings-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtpHost">SMTP Host</label>
|
||||
<input type="text" id="smtpHost" name="smtp_host" autocomplete="off" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtpPort">SMTP Port</label>
|
||||
<input type="number" id="smtpPort" name="smtp_port" min="1" max="65535" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtpUsername">SMTP Username</label>
|
||||
<input type="text" id="smtpUsername" name="smtp_username" autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtpPassword">SMTP Password</label>
|
||||
<input type="password" id="smtpPassword" name="smtp_password" autocomplete="current-password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtpSender">Sender Address</label>
|
||||
<input type="email" id="smtpSender" name="smtp_sender" autocomplete="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtpRecipients">Notification Recipients</label>
|
||||
<textarea id="smtpRecipients" name="smtp_recipients" rows="3" placeholder="comma-separated emails"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="smtpUseTls" name="smtp_use_tls" />
|
||||
Use TLS when sending mail
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="notifyContactForm" name="notify_contact_form" />
|
||||
Email notifications for new contact submissions
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="notifyNewsletter" name="notify_newsletter_signups" />
|
||||
Confirmation emails for newsletter signups
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<button type="submit" class="btn btn-primary">Save Email Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,20 +1,41 @@
|
||||
{% extends "_base.html" %} {% block title %}Email Templates{% endblock %} {%
|
||||
block heading %}Email Templates{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div class="settings-management">
|
||||
<h2>Newsletter Confirmation Template</h2>
|
||||
<p>Edit the HTML template used for the newsletter confirmation email.</p>
|
||||
<div id="message"></div>
|
||||
<form id="templateForm">
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
{% endblock %} {% block content %}
|
||||
<div class="email-templates-container" id="emailTemplatesPage">
|
||||
<aside
|
||||
class="email-templates-list"
|
||||
id="emailTemplatesList"
|
||||
aria-label="Available email templates"
|
||||
></aside>
|
||||
|
||||
<section class="email-template-editor">
|
||||
<div id="templateMessage" class="message" style="display: none"></div>
|
||||
<h2 id="templateTitle">Select a template to start editing</h2>
|
||||
<p id="templateDescription" class="template-description"></p>
|
||||
|
||||
<form id="emailTemplateForm">
|
||||
<div class="form-group">
|
||||
<label for="newsletterTemplate">Template HTML</label>
|
||||
<textarea id="newsletterTemplate" rows="15" cols="80"></textarea>
|
||||
<label for="templateContent">Template HTML</label>
|
||||
<textarea
|
||||
id="templateContent"
|
||||
rows="20"
|
||||
class="template-content"
|
||||
disabled
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" type="submit">Save Template</button>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
id="saveTemplateButton"
|
||||
disabled
|
||||
>
|
||||
Save Template
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
|
||||
@@ -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,9 +33,8 @@ 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(
|
||||
{
|
||||
def test_send_notification_sends_email(monkeypatch):
|
||||
smtp_config = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 2525,
|
||||
"sender": "sender@example.com",
|
||||
@@ -43,7 +42,13 @@ def test_send_notification_sends_email(monkeypatch, patched_settings):
|
||||
"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