diff --git a/web/blueprints/group_admin.py b/web/blueprints/group_admin.py index 4997278..7bd1f8f 100644 --- a/web/blueprints/group_admin.py +++ b/web/blueprints/group_admin.py @@ -5,6 +5,8 @@ Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten. import json from functools import wraps from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from config import Config +from mailer import send_mail import panel_db as db group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin") @@ -118,7 +120,23 @@ def member_invite(): token = db.create_group_invite(group_id, username, email, role, session["user_id"]) invite_url = url_for("auth.accept_invite", token=token, _external=True) - flash(f"Invitation created for '{username}'. Share this link: {invite_url}", "success") + mail_settings = db.get_site_mail_settings() + + if mail_settings: + subject = f"Invitation to join {session.get('group_name', 'your group')}" + text_body = ( + f"Hello {username},\n\n" + f"You have been invited to join the group '{session.get('group_name', 'your group')}' on MCLogger as {role}.\n" + f"Open this link to create your account:\n\n{invite_url}\n\n" + f"This invite expires in {Config.INVITE_EXPIRY_HOURS} hours.\n" + ) + try: + send_mail(mail_settings, email, subject, text_body) + flash(f"Invitation email sent to '{email}'.", "success") + except Exception: + flash(f"Invitation created, but email delivery failed. Share this link manually: {invite_url}", "warning") + else: + flash(f"Invitation created for '{username}'. Share this link: {invite_url}", "success") return redirect(url_for("group_admin.members")) diff --git a/web/blueprints/site_admin.py b/web/blueprints/site_admin.py index 4967f30..c242ee2 100644 --- a/web/blueprints/site_admin.py +++ b/web/blueprints/site_admin.py @@ -4,6 +4,7 @@ Verwaltet alle Gruppen und Nutzer global. """ from functools import wraps from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from mailer import send_mail import panel_db as db site_admin = Blueprint("site_admin", __name__, url_prefix="/admin") @@ -28,22 +29,101 @@ def dashboard(): try: groups = db.list_all_groups() or [] users = db.list_all_users() or [] + has_mail = db.has_site_mail_settings() for g in groups: try: g["has_db"] = db.has_db_configured(g["id"]) except Exception: g["has_db"] = False except Exception: - groups, users = [], [] + groups, users, has_mail = [], [], False stats = { "group_count": len(groups), "user_count": len(users), "db_configured": sum(1 for g in groups if g.get("has_db")), "admin_count": sum(1 for u in users if u.get("is_site_admin")), + "mail_configured": int(has_mail), } return render_template("admin/dashboard.html", groups=groups, users=users, stats=stats) +@site_admin.route("/mail", methods=["GET", "POST"]) +@admin_required +def mail_settings(): + settings = db.get_site_mail_settings() + error = None + + if request.method == "POST": + host = request.form.get("host", "").strip() + port = request.form.get("port", type=int) or 0 + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + from_email = request.form.get("from_email", "").strip() + from_name = request.form.get("from_name", "").strip() + use_tls = request.form.get("use_tls") == "1" + action = request.form.get("action", "save") + test_recipient = request.form.get("test_recipient", "").strip() + + if settings and not password: + password = settings["password"] + + if not all([host, port, username, password, from_email]): + error = "Host, port, username, password and from email are required." + elif "@" not in from_email: + error = "Please provide a valid from email address." + else: + candidate = { + "host": host, + "port": port, + "username": username, + "password": password, + "from_email": from_email, + "from_name": from_name, + "use_tls": use_tls, + } + try: + if action == "test": + send_mail( + candidate, + test_recipient or from_email, + "MCLogger SMTP Test", + "This is a test email from your MCLogger admin panel.", + ) + flash("Test email sent successfully.", "success") + else: + send_mail( + candidate, + test_recipient or from_email, + "MCLogger SMTP Verification", + "Your SMTP settings were verified successfully and have been saved.", + ) + db.set_site_mail_settings(host, port, username, password, from_email, from_name, use_tls) + flash("Mail settings saved and verified.", "success") + return redirect(url_for("site_admin.mail_settings")) + except Exception as exc: + error = f"SMTP connection failed: {exc}" + + settings = { + "host": host, + "port": port, + "username": username, + "password": password, + "from_email": from_email, + "from_name": from_name, + "use_tls": use_tls, + } + + return render_template("admin/mail_settings.html", settings=settings, error=error) + + +@site_admin.route("/mail/delete", methods=["POST"]) +@admin_required +def mail_settings_delete(): + db.delete_site_mail_settings() + flash("Mail settings removed.", "success") + return redirect(url_for("site_admin.mail_settings")) + + # ────────────────────────────────────────────────────────────── # Gruppen verwalten # ────────────────────────────────────────────────────────────── diff --git a/web/config.py b/web/config.py index 31aed61..f33744d 100644 --- a/web/config.py +++ b/web/config.py @@ -48,6 +48,11 @@ class Config: # Generieren: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" FERNET_KEY = os.getenv("FERNET_KEY", "") + # ── Mail defaults (can be overridden in admin panel) ───── + MAIL_PORT = int(os.getenv("MAIL_PORT") or "587") + MAIL_USE_TLS = _as_bool(os.getenv("MAIL_USE_TLS"), default=True) + MAIL_TIMEOUT = int(os.getenv("MAIL_TIMEOUT") or "15") + # ── Standard-Berechtigungen neuer Gruppenmitglieder ─────── INVITE_EXPIRY_HOURS = int(os.getenv("INVITE_EXPIRY_HOURS") or "72") diff --git a/web/mailer.py b/web/mailer.py new file mode 100644 index 0000000..7ec7447 --- /dev/null +++ b/web/mailer.py @@ -0,0 +1,27 @@ +import smtplib +from email.message import EmailMessage + +from config import Config + + +def build_from_header(from_email: str, from_name: str | None = None) -> str: + if from_name: + return f"{from_name} <{from_email}>" + return from_email + + + +def send_mail(settings: dict, recipient: str, subject: str, text_body: str): + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = build_from_header(settings["from_email"], settings.get("from_name")) + msg["To"] = recipient + msg.set_content(text_body) + + with smtplib.SMTP(settings["host"], settings["port"], timeout=Config.MAIL_TIMEOUT) as smtp: + smtp.ehlo() + if settings.get("use_tls", True): + smtp.starttls() + smtp.ehlo() + smtp.login(settings["username"], settings["password"]) + smtp.send_message(msg) diff --git a/web/panel_db.py b/web/panel_db.py index 6e47f7b..8a348cd 100644 --- a/web/panel_db.py +++ b/web/panel_db.py @@ -130,6 +130,19 @@ CREDS_SCHEMA = [ enc_database TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + + """CREATE TABLE IF NOT EXISTS site_mail_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + config_key VARCHAR(50) UNIQUE NOT NULL, + enc_host TEXT NOT NULL, + enc_port TEXT NOT NULL, + enc_username TEXT NOT NULL, + enc_password TEXT NOT NULL, + enc_from_email TEXT NOT NULL, + enc_from_name TEXT, + enc_use_tls TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", ] @@ -494,3 +507,48 @@ def has_db_configured(group_id: int) -> bool: (group_id,), fetchone=True ) return row is not None + + +def set_site_mail_settings(host: str, port: int, username: str, password: str, from_email: str, from_name: str, use_tls: bool): + _creds_query( + "INSERT INTO site_mail_settings (config_key, enc_host, enc_port, enc_username, enc_password, enc_from_email, enc_from_name, enc_use_tls) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s) " + "ON DUPLICATE KEY UPDATE enc_host=VALUES(enc_host), enc_port=VALUES(enc_port), enc_username=VALUES(enc_username), " + "enc_password=VALUES(enc_password), enc_from_email=VALUES(enc_from_email), enc_from_name=VALUES(enc_from_name), enc_use_tls=VALUES(enc_use_tls)", + ( + "primary", + encrypt_str(host), + encrypt_str(str(port)), + encrypt_str(username), + encrypt_str(password), + encrypt_str(from_email), + encrypt_str(from_name or ""), + encrypt_str("1" if use_tls else "0"), + ), + write=True, + ) + + +def get_site_mail_settings() -> dict | None: + row = _creds_query("SELECT * FROM site_mail_settings WHERE config_key=%s", ("primary",), fetchone=True) + if not row: + return None + return { + "host": decrypt_str(row["enc_host"]), + "port": int(decrypt_str(row["enc_port"])), + "username": decrypt_str(row["enc_username"]), + "password": decrypt_str(row["enc_password"]), + "from_email": decrypt_str(row["enc_from_email"]), + "from_name": decrypt_str(row["enc_from_name"]) if row.get("enc_from_name") else "", + "use_tls": decrypt_str(row["enc_use_tls"]) == "1", + "updated_at": row["updated_at"], + } + + +def delete_site_mail_settings(): + _creds_query("DELETE FROM site_mail_settings WHERE config_key=%s", ("primary",), write=True) + + +def has_site_mail_settings() -> bool: + row = _creds_query("SELECT id FROM site_mail_settings WHERE config_key=%s", ("primary",), fetchone=True) + return row is not None diff --git a/web/templates/admin/base.html b/web/templates/admin/base.html index 1f27289..35eae1e 100644 --- a/web/templates/admin/base.html +++ b/web/templates/admin/base.html @@ -18,6 +18,7 @@ Dashboard Groups Users + Mail
+ + +
+ + {% if settings %} +
+ + +
+ {% endif %} + + + + +
+
+
How to use your no-reply mailbox
+
+

Use your SMTP server details here. IMAP4 and POP3 are not needed for sending mail.

+
    +
  • SMTP Host: your provider's outgoing mail server
  • +
  • Port: usually 587 for STARTTLS
  • +
  • SMTP Username: often the full mailbox address
  • +
  • From Email: your no-reply address, for example noreply@yourdomain.com
  • +
  • Use STARTTLS: keep enabled for your setup
  • +
+
+
+
+ +{% endblock %}