email in admin
Build LinkLog Development Image / development-image (push) Successful in 9s

This commit is contained in:
2026-08-26 12:38:38 +02:00
parent 158bc64268
commit fec4c8def5
7 changed files with 337 additions and 16 deletions
+102 -1
View File
@@ -2,6 +2,7 @@
## SPDX-License-Identifier: GPL-3.0-or-later
import json
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
@@ -10,7 +11,13 @@ from pydantic import BaseModel
from backend.app.api.dependencies import require_admin
from backend.app.database import get_connection, hash_password
from backend.app.services.link_service import delete_label
from backend.app.services.email_service import send_verification_email, smtp_configured
from backend.app.services.email_service import (
get_smtp_settings,
save_smtp_settings,
send_test_email,
send_verification_email,
smtp_configured,
)
from backend.app.services.email_verification import create_verification_token
from backend.app.core.config import settings
@@ -33,6 +40,32 @@ class AdminUserUpdate(BaseModel):
is_admin: bool
class AdminSmtpUpdate(BaseModel):
smtp_host: str
smtp_port: int = 587
smtp_username: str = ''
smtp_password: str = ''
smtp_from: str
smtp_use_tls: bool = True
def validate_smtp_values(payload: AdminSmtpUpdate, current: dict | None = None) -> dict:
smtp_host = payload.smtp_host.strip()
smtp_from = payload.smtp_from.strip()
if not smtp_host or not smtp_from:
raise HTTPException(status_code=422, detail='SMTP host and sender address are required')
if not 1 <= payload.smtp_port <= 65535:
raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535')
return {
'smtp_host': smtp_host,
'smtp_port': payload.smtp_port,
'smtp_username': payload.smtp_username.strip(),
'smtp_password': payload.smtp_password or (current or {}).get('smtp_password', ''),
'smtp_from': smtp_from,
'smtp_use_tls': payload.smtp_use_tls,
}
def public_user(row):
return {
'id': row['id'],
@@ -90,6 +123,74 @@ def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)):
return public_user(row)
def public_smtp_settings(values: dict) -> dict:
return {
'smtp_host': values['smtp_host'],
'smtp_port': values['smtp_port'],
'smtp_username': values['smtp_username'],
'smtp_from': values['smtp_from'],
'smtp_use_tls': values['smtp_use_tls'],
'password_configured': bool(values['smtp_password']),
}
@router.get('/smtp')
def get_admin_smtp_settings(_: dict = Depends(require_admin)):
return public_smtp_settings(get_smtp_settings())
@router.put('/smtp')
def update_admin_smtp_settings(payload: AdminSmtpUpdate, _: dict = Depends(require_admin)):
current = get_smtp_settings()
values = validate_smtp_values(payload, current)
save_smtp_settings(values)
return public_smtp_settings(values)
@router.post('/smtp/test')
def validate_admin_smtp(payload: AdminSmtpUpdate, current_user: dict = Depends(require_admin)):
values = validate_smtp_values(payload, get_smtp_settings())
now = datetime.now(timezone.utc)
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('admin_smtp_mail_rate',)).fetchone()
rate = json.loads(row['value']) if row else {}
last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None
cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None
if cooldown_until and now >= cooldown_until:
rate = {}
last_sent = None
cooldown_until = None
if cooldown_until and now < cooldown_until:
retry_after = int((cooldown_until - now).total_seconds()) + 1
raise HTTPException(status_code=429, detail=f'SMTP validation limit reached. Try again in {retry_after} seconds.', headers={'Retry-After': str(retry_after)})
if last_sent and now - last_sent < timedelta(seconds=20):
retry_after = int((timedelta(seconds=20) - (now - last_sent)).total_seconds()) + 1
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before sending another validation email.', headers={'Retry-After': str(retry_after)})
try:
send_test_email(current_user['email'], values)
except Exception as error:
raise HTTPException(status_code=503, detail=f'SMTP validation failed: {error}') from error
sends = int(rate.get('sends', 0)) + 1
updated_rate = {'sends': sends, 'last_sent': now.isoformat()}
if sends >= 5:
updated_rate['cooldown_until'] = (now + timedelta(minutes=2)).isoformat()
with get_connection() as conn:
conn.execute(
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
('admin_smtp_mail_rate', json.dumps(updated_rate)),
)
conn.commit()
next_allowed = datetime.fromisoformat(updated_rate.get('cooldown_until')) if sends >= 5 else now + timedelta(seconds=20)
return {
'status': 'sent',
'message': f'SMTP validation email sent to {current_user["email"]}.',
'sends_remaining': max(0, 5 - sends),
'cooldown_seconds': 120 if sends >= 5 else 0,
'next_allowed_at': next_allowed.isoformat(),
}
@router.put('/users/{user_id}')
def update_user_privileges(
user_id: str,