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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user