v1
This commit is contained in:
1
server/services/__init__.py
Normal file
1
server/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service layer namespace."""
|
||||
112
server/services/contact.py
Normal file
112
server/services/contact.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Business logic for contact submissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from .. import settings
|
||||
from ..database import save_contact
|
||||
from ..metrics import record_submission
|
||||
from ..utils import is_valid_email
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContactSubmission:
|
||||
name: str
|
||||
email: str
|
||||
company: str | None
|
||||
message: str
|
||||
timeline: str | None
|
||||
created_at: str = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def validate_submission(raw: Dict[str, Any]) -> Tuple[ContactSubmission | None, Dict[str, str]]:
|
||||
"""Validate the incoming payload and return a submission object."""
|
||||
name = (raw.get("name") or "").strip()
|
||||
email = (raw.get("email") or "").strip()
|
||||
message = (raw.get("message") or "").strip()
|
||||
consent = raw.get("consent")
|
||||
company = (raw.get("company") or "").strip()
|
||||
|
||||
errors: Dict[str, str] = {}
|
||||
if not name:
|
||||
errors["name"] = "Name is required."
|
||||
elif len(name) > 200:
|
||||
errors["name"] = "Name is too long (max 200 chars)."
|
||||
if not is_valid_email(email):
|
||||
errors["email"] = "Valid email is required."
|
||||
if not message:
|
||||
errors["message"] = "Message is required."
|
||||
elif len(message) > 5000:
|
||||
errors["message"] = "Message is too long (max 5000 chars)."
|
||||
if not consent:
|
||||
errors["consent"] = "Consent is required."
|
||||
if company and len(company) > 200:
|
||||
errors["company"] = "Organisation name is too long (max 200 chars)."
|
||||
|
||||
if errors:
|
||||
return None, errors
|
||||
|
||||
submission = ContactSubmission(
|
||||
name=name,
|
||||
email=email,
|
||||
company=company or None,
|
||||
message=message,
|
||||
timeline=(raw.get("timeline") or "").strip() or None,
|
||||
)
|
||||
return submission, {}
|
||||
|
||||
|
||||
def persist_submission(submission: ContactSubmission) -> int:
|
||||
"""Persist the submission and update metrics."""
|
||||
record_id = save_contact(submission)
|
||||
record_submission()
|
||||
return record_id
|
||||
|
||||
|
||||
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"]:
|
||||
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"]
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"Neue Kontaktanfrage von {submission.name}"
|
||||
msg["From"] = sender
|
||||
msg["To"] = ", ".join(recipients)
|
||||
msg.set_content(
|
||||
"\n".join(
|
||||
[
|
||||
f"Name: {submission.name}",
|
||||
f"E-Mail: {submission.email}",
|
||||
f"Organisation: {submission.company or '—'}",
|
||||
f"Zeithorizont: {submission.timeline or '—'}",
|
||||
"",
|
||||
"Nachricht:",
|
||||
submission.message,
|
||||
"",
|
||||
f"Eingang: {submission.created_at}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(settings.SMTP_SETTINGS["host"], settings.SMTP_SETTINGS["port"], timeout=15) as server:
|
||||
if settings.SMTP_SETTINGS["use_tls"]:
|
||||
server.starttls()
|
||||
if settings.SMTP_SETTINGS["username"]:
|
||||
server.login(
|
||||
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"] or "")
|
||||
server.send_message(msg)
|
||||
logging.info("Notification email dispatched to %s", recipients)
|
||||
return True
|
||||
except Exception as exc: # pragma: no cover - SMTP failures are logged only
|
||||
logging.error("Failed to send notification email: %s", exc)
|
||||
return False
|
||||
96
server/services/newsletter.py
Normal file
96
server/services/newsletter.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Business logic for newsletter subscriptions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import save_subscriber, delete_subscriber, update_subscriber
|
||||
from ..utils import is_valid_email
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Return True when the provided email passes a basic sanity check."""
|
||||
return is_valid_email(email)
|
||||
|
||||
|
||||
def subscribe(email: str) -> bool:
|
||||
"""Persist the subscription and return False when it already exists."""
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
return save_subscriber(email, created_at=created_at)
|
||||
|
||||
|
||||
def unsubscribe(email: str) -> bool:
|
||||
"""Remove the subscription and return True if it existed."""
|
||||
return delete_subscriber(email)
|
||||
|
||||
|
||||
def update_email(old_email: str, new_email: str) -> bool:
|
||||
"""Update the email for a subscription. Return True if updated."""
|
||||
return update_subscriber(old_email, new_email)
|
||||
|
||||
|
||||
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
|
||||
|
||||
if not settings.SMTP_SETTINGS["host"]:
|
||||
logging.error("SMTP not configured, cannot send newsletter")
|
||||
return 0
|
||||
|
||||
try:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = settings.SMTP_SETTINGS["sender"] or "noreply@example.com"
|
||||
|
||||
# Format content
|
||||
formatted_content = content.replace('\n', '<br>')
|
||||
html_content = f"""
|
||||
<html>
|
||||
<body>
|
||||
{formatted_content}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Add HTML content
|
||||
html_part = MIMEText(html_content, 'html')
|
||||
msg.attach(html_part)
|
||||
|
||||
# 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"]:
|
||||
server.starttls()
|
||||
|
||||
if settings.SMTP_SETTINGS["username"] and settings.SMTP_SETTINGS["password"]:
|
||||
server.login(
|
||||
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"])
|
||||
|
||||
for email in emails:
|
||||
try:
|
||||
# Create a fresh copy for each recipient
|
||||
recipient_msg = MIMEMultipart('alternative')
|
||||
recipient_msg['Subject'] = subject
|
||||
recipient_msg['From'] = msg['From']
|
||||
recipient_msg['To'] = email
|
||||
|
||||
# Add HTML content
|
||||
recipient_msg.attach(MIMEText(html_content, 'html'))
|
||||
|
||||
server.sendmail(msg['From'], email,
|
||||
recipient_msg.as_string())
|
||||
success_count += 1
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to send newsletter to %s: %s", email, exc)
|
||||
|
||||
return success_count
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to send newsletter: %s", exc)
|
||||
return 0
|
||||
Reference in New Issue
Block a user