modified: web/blueprints/group_admin.py

modified:   web/blueprints/site_admin.py
	modified:   web/config.py
	new file:   web/mailer.py
	modified:   web/panel_db.py
	modified:   web/templates/admin/base.html
	modified:   web/templates/admin/dashboard.html
	new file:   web/templates/admin/mail_settings.html
This commit is contained in:
simon
2026-04-13 10:29:48 +02:00
parent 6b13ea5c22
commit 63ce0f9c5b
8 changed files with 296 additions and 2 deletions

View File

@@ -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"))

View File

@@ -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
# ──────────────────────────────────────────────────────────────

View File

@@ -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")

27
web/mailer.py Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -18,6 +18,7 @@
<a href="{{ url_for('site_admin.dashboard') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.dashboard' }}">Dashboard</a>
<a href="{{ url_for('site_admin.groups') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.groups' }}">Groups</a>
<a href="{{ url_for('site_admin.users') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.users' }}">Users</a>
<a href="{{ url_for('site_admin.mail_settings') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.mail_settings' }}">Mail</a>
<form method="post" action="{{ url_for('auth.logout') }}" class="d-inline">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-light btn-sm">

View File

@@ -36,6 +36,14 @@
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 bg-secondary bg-opacity-25">
<div class="card-body text-center">
<div class="fs-2 fw-bold {{ 'text-success' if stats.mail_configured else 'text-secondary' }}">{{ stats.mail_configured }}</div>
<div class="text-muted">Mail configured</div>
</div>
</div>
</div>
</div>
<div class="row g-3">

View File

@@ -0,0 +1,97 @@
{% extends "admin/base.html" %}
{% block title %}Mail Settings{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-envelope-fill me-2"></i>Mail Settings</h2>
</div>
<div class="row g-3">
<div class="col-lg-7">
<div class="card border-secondary">
<div class="card-header">SMTP Configuration</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">SMTP Host</label>
<input type="text" name="host" class="form-control" required value="{{ settings.host if settings else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Port</label>
<input type="number" name="port" class="form-control" required value="{{ settings.port if settings else '587' }}">
</div>
<div class="col-md-6">
<label class="form-label">SMTP Username</label>
<input type="text" name="username" class="form-control" required value="{{ settings.username if settings else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">SMTP Password</label>
<input type="password" name="password" class="form-control" placeholder="{{ '(unchanged)' if settings else '' }}">
{% if settings %}
<div class="form-text">Leave blank to keep the stored password.</div>
{% endif %}
</div>
<div class="col-md-6">
<label class="form-label">From Email</label>
<input type="email" name="from_email" class="form-control" required value="{{ settings.from_email if settings else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">From Name</label>
<input type="text" name="from_name" class="form-control" value="{{ settings.from_name if settings else '' }}" placeholder="MCLogger">
</div>
<div class="col-md-6">
<label class="form-label">Test Recipient</label>
<input type="email" name="test_recipient" class="form-control" placeholder="Optional, defaults to From Email">
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch" name="use_tls" id="use_tls" value="1" {{ 'checked' if not settings or settings.use_tls }}>
<label class="form-check-label" for="use_tls">Use STARTTLS</label>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" name="action" value="save" class="btn btn-success">
<i class="bi bi-check2-circle me-1"></i>Save and Verify
</button>
<button type="submit" name="action" value="test" class="btn btn-outline-info">
<i class="bi bi-send-check me-1"></i>Send Test Email
</button>
</div>
</form>
{% if settings %}
<form method="post" action="{{ url_for('site_admin.mail_settings_delete') }}" class="mt-3">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Delete stored mail settings?')">
<i class="bi bi-trash3 me-1"></i>Delete Stored Settings
</button>
</form>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card border-secondary">
<div class="card-header">How to use your no-reply mailbox</div>
<div class="card-body">
<p class="text-muted small mb-2">Use your SMTP server details here. IMAP4 and POP3 are not needed for sending mail.</p>
<ul class="small text-muted mb-0">
<li>SMTP Host: your provider's outgoing mail server</li>
<li>Port: usually 587 for STARTTLS</li>
<li>SMTP Username: often the full mailbox address</li>
<li>From Email: your no-reply address, for example noreply@yourdomain.com</li>
<li>Use STARTTLS: keep enabled for your setup</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}