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:
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
27
web/mailer.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
97
web/templates/admin/mail_settings.html
Normal file
97
web/templates/admin/mail_settings.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user