i18 drin
mail drin
fehlende routen drin
prod-reife erreicht.
TODOS:
Deine aktuellen offenen TODOs
1. Rate-Limiting (Spam-Schutz VOR dem reCAPTCHA)
Das reCAPTCHA hält zwar Bots ab, aber wenn jemand böswillig ein Script schreibt, das 1.000 POST-Requests pro Sekunde an /api/v1/submissions sendet, muss dein Backend jedes Mal Google kontaktieren und die Datenbank befragen. Das frisst Server-Ressourcen.
Todo: Einbinden von Flask-Limiter und Absichern der API-Route (z.B. max. 10 Submissions pro Minute pro IP-Adresse oder Tenant).
2. Billing & Feature-Enforcement (Limits pro Tenant durchsetzen)
Die Mandanten-Architektur und das config-Feld im JSON existieren bereits. Aber wir erzwingen diese Limits aktuell nicht im Backend.
Todo: Im Dashboard (templates/admin/tenants/view.html) UI-Controls einbauen, um Feature-Flags für einen Tenant festzulegen:
max_forms (z.B. 1 für Free-User, 10 für Pro-User).
allow_instant_mail (Dürfen E-Mails an den Tenant gesendet werden?).
allow_autoresponder (Darf der Autoresponder für Endkunden genutzt werden?).
Todo: Diese Limits hart im Backend (beim Erstellen von Forms und beim Mailversand) kontrollieren. (Der Mailer prüft das zwar schon halbwegs, das UI zum Pflegen fehlt aber noch komplett).
3. E-Mail Templates aufwerten (HTML statt Plain-Text)
Bisher versendet der Service E-Mails als reinen Text (plain). Für eine professionelle SaaS-Lösung sollten diese Mails ansprechend aussehen.
Todo: Im services/mailer.py den MIMEMultipart so erweitern, dass er saubere HTML-Mails aus einem Jinja2-Template (templates/emails/...) generiert, inklusive Branding/Logo deines Services.
4. Frontend-Snippet "Graceful Degradation"
Das embed.js arbeitet aktuell sehr gut. Aber was ist, wenn das Formeleon-Backend mal für 5 Minuten down ist?
Todo: Einen sauberen Timeout im fetch einbauen und dem Endkunden auf der Webseite eine schöne, fallback HTML-Meldung anzeigen ("Entschuldigung, das Formular ist momentan nicht erreichbar"), anstatt dass sich das Script lautlos in der Console mit einem CORS/Network-Error verabschiedet.
5. CI/CD Pipeline (Deployment)
Wenn der Code fertig ist, willst du ihn sicher nicht immer händisch per git pull auf deinen Produktionsserver schieben und dort docker compose up tippen.
Todo: Eine simple Pipeline (Github Actions / Gitea Actions) anlegen, die den Code testet und automatisch auf dem Zielserver ausrollt.
This commit is contained in:
1 parent
7f81932fa5
commit
5fff17531d
31 files changed
+2683
-550
No files matched your search
@@ -0,0 +1,125 @@
|
||||
# ==============================================================================
|
||||
# Formeleon - Multi-tenant Form Generator
|
||||
# Author: Björn Runden <br@runden.net>
|
||||
# Module: services/mailer.py
|
||||
# Description: Service for handling email notifications and autoresponders.
|
||||
# ==============================================================================
|
||||
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import List, Dict, Any, Optional
|
||||
from flask import current_app
|
||||
from jinja2 import Template, TemplateError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MailerService:
|
||||
"""Service class for handling email dispatch."""
|
||||
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
"""Check if SMTP settings are provided in configuration."""
|
||||
server = current_app.config.get('SMTP_SERVER')
|
||||
# Wir betrachten den Service als "konfiguriert", wenn entweder ein Server da ist,
|
||||
# oder wir im Dev-Mode sind (um das Terminal-Logging zu erlauben)
|
||||
if current_app.config.get('DEV_MODE', False) and not server:
|
||||
return True
|
||||
|
||||
port = current_app.config.get('SMTP_PORT')
|
||||
return bool(server and port)
|
||||
|
||||
@classmethod
|
||||
def send_email(cls, to_addresses: List[str], subject: str, body_text: str, reply_to: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Sends an email using the configured SMTP server.
|
||||
|
||||
:param to_addresses: List of recipient email addresses
|
||||
:param subject: Email subject line
|
||||
:param body_text: Plain text email body
|
||||
:param reply_to: Optional Reply-To address
|
||||
:return: True if successful, False otherwise
|
||||
"""
|
||||
if not cls.is_configured():
|
||||
logger.warning("MailerService: Attempted to send email, but SMTP is not configured.")
|
||||
return False
|
||||
|
||||
if current_app.config.get('DEV_MODE', False) and not current_app.config.get('SMTP_SERVER'):
|
||||
logger.info(f"MailerService [DEV MODE]: Would send email to {to_addresses} | Subject: '{subject}'")
|
||||
return True
|
||||
|
||||
smtp_server = current_app.config.get('SMTP_SERVER')
|
||||
smtp_port = int(current_app.config.get('SMTP_PORT', 587))
|
||||
smtp_user = current_app.config.get('SMTP_USERNAME')
|
||||
smtp_pass = current_app.config.get('SMTP_PASSWORD')
|
||||
use_tls = str(current_app.config.get('SMTP_USE_TLS', 'true')).lower() == 'true'
|
||||
from_address = current_app.config.get('EMAIL_FROM', 'formeleon@localhost')
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = from_address
|
||||
msg['To'] = ", ".join(to_addresses)
|
||||
msg['Subject'] = subject
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
|
||||
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(smtp_server, smtp_port) as server:
|
||||
if use_tls:
|
||||
server.starttls()
|
||||
if smtp_user and smtp_pass:
|
||||
server.login(smtp_user, smtp_pass)
|
||||
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"Successfully sent email to {to_addresses}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to_addresses}: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def render_template(cls, template_string: str, payload: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Renders a Jinja2 template string using the submission payload.
|
||||
|
||||
:param template_string: The template string (e.g., "Hello {{ name }}")
|
||||
:param payload: Dictionary containing the form submission data
|
||||
:return: Rendered string
|
||||
"""
|
||||
try:
|
||||
template = Template(template_string)
|
||||
return template.render(**payload)
|
||||
except TemplateError as e:
|
||||
logger.error(f"Template rendering failed: {str(e)}")
|
||||
# Fallback: Return original string if rendering fails
|
||||
return template_string
|
||||
|
||||
@classmethod
|
||||
def send_admin_notification(cls, form_name: str, to_addresses: List[str], payload: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Sends an alert to the tenant admins about a new submission.
|
||||
"""
|
||||
subject = f"New Submission: {form_name}"
|
||||
|
||||
# Build a simple readable key-value body
|
||||
body_lines = [f"A new submission was received for the form '{form_name}'.\n", "-" * 40]
|
||||
for key, value in payload.items():
|
||||
body_lines.append(f"{key}: {value}")
|
||||
body_lines.append("-" * 40)
|
||||
|
||||
body_text = "\n".join(body_lines)
|
||||
return cls.send_email(to_addresses, subject, body_text)
|
||||
|
||||
@classmethod
|
||||
def send_autoresponder(cls, to_address: str, subject_template: str, body_template: str, payload: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Sends a templated autoresponder to the person who submitted the form.
|
||||
"""
|
||||
subject = cls.render_template(subject_template, payload)
|
||||
body = cls.render_template(body_template, payload)
|
||||
|
||||
return cls.send_email([to_address], subject, body)
|
||||
Reference in new issue
Block a user