diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index b5bb1b8..ff613d1 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -246,6 +246,26 @@ Create a Spanish, German, French and Dutch locale. ### Assistant outcome Added complete Spanish (`es`), German (`de`), French (`fr`), and Dutch (`nl`) Firefox locale catalogs and updated XPI packaging and validation to include all four locales. +## 2026-08-26 + +### User +On the admin page add the SMTP settings (with the validation button). + +### Assistant outcome +Added administrator-only SMTP settings APIs and an admin-page form for loading, saving, and validating SMTP delivery. SMTP passwords are never returned and blank password updates preserve the existing credential. The validation email is sent to the signed-in administrator. + +### User +Populate the SMTP fields with current values except the password. Send test mail to the authenticated administrator, use unsaved fields for testing, and apply the configuration page's testing limits. + +### Assistant outcome +The admin form now loads effective SMTP settings without exposing the password. Validation sends the current form values directly to the authenticated administrator, without saving them first, while enforcing the same five-send limit, 20-second interval, two-minute cooldown, and countdown behavior used by initial setup. + +### User +Report any errors that may occur from the SMTP module to the user. + +### Assistant outcome +Updated admin SMTP, user-creation, and validation error handling to display backend SMTP error details and network failures in the UI. Added regression coverage confirming SMTP delivery errors are returned as actionable `503` details. + ### User Show the avatar with each entry on the home page. On the specific // page don't show the avatar and user name with each entry diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 540423c..ceb3080 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -127,6 +127,9 @@ 118. On the home page, when clicking on the avatar or the user, show the profile information. 119. Localize the firefox plugin. 120. Create a spanish, german, french and dutch locale. +121. On the admin page add the SMTP settings (with the validation button). +122. Populate the SMTP fields with the current values except for the password. Send the testmail to the currently authenticated admin user. Only use updated fields when testing. Use the same logic as in the configuration page to restrict endless testing. +123. Report any errors that may occur from the SMTP module to the user. ## Future entries diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 52c95fb..9a6183d 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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, diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 28b75d9..468d663 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -40,8 +40,8 @@ def smtp_configured() -> bool: return bool(smtp['smtp_host'] and smtp['smtp_from']) -def send_message(email: str, subject: str, body: str) -> None: - smtp = get_smtp_settings() +def send_message(email: str, subject: str, body: str, smtp_values: dict | None = None) -> None: + smtp = smtp_values or get_smtp_settings() if not smtp_configured(): raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM') @@ -69,8 +69,13 @@ def send_verification_email(email: str, username: str, verification_url: str) -> ) -def send_test_email(email: str) -> None: - send_message(email, 'LinkLog SMTP test', 'This is a test message from LinkLog. SMTP is configured correctly.\n') +def send_test_email(email: str, smtp_values: dict | None = None) -> None: + send_message( + email, + 'LinkLog SMTP test', + 'This is a test message from LinkLog. SMTP is configured correctly.\n', + smtp_values, + ) def send_password_reset_email(email: str, username: str, reset_url: str) -> None: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index f68147b..358e84d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -12,6 +12,7 @@ from fastapi.testclient import TestClient from backend.app.main import app from backend.app.database import get_connection +from backend.app.services.email_service import get_smtp_settings from backend.app.services.password_reset import create_reset_token from backend.app.services.token_service import issue_token @@ -57,6 +58,64 @@ def test_configuration_requires_authentication_and_admin_role(): assert client.get('/api/admin/users').status_code == 401 assert client.get('/api/admin/plugins', headers=login_headers('bob')).status_code == 403 assert client.get('/api/admin/users', headers=login_headers('bob')).status_code == 403 + assert client.get('/api/admin/smtp').status_code == 401 + assert client.get('/api/admin/smtp', headers=login_headers('bob')).status_code == 403 + + +def test_admin_can_save_and_validate_smtp_settings(): + headers = login_headers() + original = get_smtp_settings() + response = client.put('/api/admin/smtp', headers=headers, json={ + 'smtp_host': 'smtp.example.com', + 'smtp_port': 587, + 'smtp_username': 'mailer', + 'smtp_password': 'secret', + 'smtp_from': 'LinkLog ', + 'smtp_use_tls': True, + }) + assert response.status_code == 200 + assert response.json()['smtp_host'] == 'smtp.example.com' + assert response.json()['password_configured'] is True + assert 'smtp_password' not in response.json() + + with patch('backend.app.api.admin.send_test_email') as send_test_email: + validation = client.post('/api/admin/smtp/test', headers=headers, json={ + 'smtp_host': 'smtp.unsaved.example.com', + 'smtp_port': 2525, + 'smtp_username': 'temporary-user', + 'smtp_password': 'temporary-secret', + 'smtp_from': 'Temporary ', + 'smtp_use_tls': False, + }) + assert validation.status_code == 200 + send_test_email.assert_called_once_with('alice@example.com', { + 'smtp_host': 'smtp.unsaved.example.com', + 'smtp_port': 2525, + 'smtp_username': 'temporary-user', + 'smtp_password': 'temporary-secret', + 'smtp_from': 'Temporary ', + 'smtp_use_tls': False, + }) + + client.put('/api/admin/smtp', headers=headers, json=original) + + +def test_admin_reports_smtp_validation_errors(): + headers = login_headers() + with get_connection() as conn: + conn.execute('DELETE FROM app_settings WHERE name = ?', ('admin_smtp_mail_rate',)) + conn.commit() + with patch('backend.app.api.admin.send_test_email', side_effect=RuntimeError('connection refused')): + failed_validation = client.post('/api/admin/smtp/test', headers=headers, json={ + 'smtp_host': 'smtp.unsaved.example.com', + 'smtp_port': 2525, + 'smtp_username': 'temporary-user', + 'smtp_password': 'temporary-secret', + 'smtp_from': 'Temporary ', + 'smtp_use_tls': False, + }) + assert failed_validation.status_code == 503 + assert 'connection refused' in failed_validation.json()['detail'] def test_admin_can_add_list_and_remove_users(): @@ -66,6 +125,7 @@ def test_admin_can_add_list_and_remove_users(): 'email': 'charlie@example.com', 'password': 'charlie-secret', }) + assert create_response.status_code == 201 user = create_response.json() assert user['username'] == 'charlie' diff --git a/frontend/static/admin.js b/frontend/static/admin.js index ef1cdfe..6237c40 100644 --- a/frontend/static/admin.js +++ b/frontend/static/admin.js @@ -8,6 +8,11 @@ const userList = document.querySelector('#user-list'); const userForm = document.querySelector('#user-form'); const adminControls = document.querySelector('#admin-controls'); const adminAuthNotice = document.querySelector('#admin-auth-notice'); +const smtpForm = document.querySelector('#smtp-form'); +const smtpTestButton = document.querySelector('#smtp-test-button'); +const smtpStatus = document.querySelector('#smtp-status'); +let smtpNextAllowedAt = null; +let smtpTimerHandle = null; const accessToken = localStorage.getItem('linklogAccessToken'); let currentUserId = null; @@ -18,6 +23,15 @@ function authHeaders(includeJson = false) { }; } +async function responseError(response, fallback) { + try { + const result = await response.json(); + return result.detail || result.message || fallback; + } catch (error) { + return fallback; + } +} + function renderPlugins(plugins) { pluginList.replaceChildren(...plugins.map((plugin) => { const row = document.createElement('div'); @@ -61,6 +75,44 @@ async function loadLabels() { renderLabels(await response.json()); } +function showSmtpStatus(message, isError = false) { + smtpStatus.textContent = message; + smtpStatus.style.color = isError ? '#f38ba8' : '#94e2d5'; +} + +function updateSmtpTimer() { + if (smtpTimerHandle) window.clearTimeout(smtpTimerHandle); + if (!smtpNextAllowedAt) { + smtpTestButton.disabled = false; + return; + } + const seconds = Math.max(0, Math.ceil((smtpNextAllowedAt - Date.now()) / 1000)); + if (seconds === 0) { + smtpNextAllowedAt = null; + updateSmtpTimer(); + return; + } + const minutes = Math.floor(seconds / 60); + showSmtpStatus(`Next validation email available in ${minutes ? `${minutes}m ` : ''}${seconds % 60}s.`); + smtpTestButton.disabled = true; + smtpTimerHandle = window.setTimeout(updateSmtpTimer, 1000); +} + +async function loadSmtpSettings() { + const response = await fetch('/api/admin/smtp', {headers: authHeaders()}); + if (!response.ok) throw new Error('Could not load SMTP settings'); + const settings = await response.json(); + for (const [name, value] of Object.entries(settings)) { + const field = smtpForm.elements[name]; + if (!field || name === 'password_configured') continue; + if (field.type === 'checkbox') { + field.checked = value; + } else { + field.value = value; + } + } +} + function renderUsers(users) { userList.replaceChildren(...users.map((user) => { const row = document.createElement('div'); @@ -133,7 +185,7 @@ async function loadAdminState() { return; } - await Promise.all([loadUsers(), loadPlugins(), loadLabels()]); + await Promise.all([loadUsers(), loadPlugins(), loadLabels(), loadSmtpSettings()]); showAdminState(true); } @@ -199,20 +251,67 @@ userForm.addEventListener('submit', async (event) => { event.preventDefault(); const values = Object.fromEntries(new FormData(userForm)); values.is_admin = userForm.elements.is_admin.checked; - const response = await fetch('/api/admin/users', { - method: 'POST', - headers: authHeaders(true), - body: JSON.stringify(values), - }); const status = document.querySelector('#user-status'); - status.textContent = response.ok ? 'User added.' : 'Could not add user.'; - status.style.color = response.ok ? '#94e2d5' : '#f38ba8'; - if (response.ok) { - userForm.reset(); - await loadUsers(); + try { + const response = await fetch('/api/admin/users', { + method: 'POST', + headers: authHeaders(true), + body: JSON.stringify(values), + }); + status.textContent = response.ok ? 'User added.' : await responseError(response, 'Could not add user.'); + status.style.color = response.ok ? '#94e2d5' : '#f38ba8'; + if (response.ok) { + userForm.reset(); + await loadUsers(); + } + } catch (error) { + status.textContent = `Could not add user: ${error.message}`; + status.style.color = '#f38ba8'; } }); +smtpForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const values = Object.fromEntries(new FormData(smtpForm)); + values.smtp_port = Number(values.smtp_port); + values.smtp_use_tls = smtpForm.elements.smtp_use_tls.checked; + try { + const response = await fetch('/api/admin/smtp', { + method: 'PUT', + headers: authHeaders(true), + body: JSON.stringify(values), + }); + showSmtpStatus(response.ok ? 'SMTP settings saved.' : await responseError(response, 'Could not save SMTP settings.'), !response.ok); + if (response.ok) smtpForm.elements.smtp_password.value = ''; + } catch (error) { + showSmtpStatus(`Could not save SMTP settings: ${error.message}`, true); + } +}); + +smtpTestButton.addEventListener('click', async () => { + smtpTestButton.disabled = true; + const values = Object.fromEntries(new FormData(smtpForm)); + values.smtp_port = Number(values.smtp_port); + values.smtp_use_tls = smtpForm.elements.smtp_use_tls.checked; + try { + const response = await fetch('/api/admin/smtp/test', { + method: 'POST', + headers: authHeaders(true), + body: JSON.stringify(values), + }); + const result = response.ok ? await response.json() : {}; + showSmtpStatus(response.ok ? result.message : await responseError(response, 'SMTP validation failed.'), !response.ok); + if (response.headers.get('Retry-After')) { + smtpNextAllowedAt = Date.now() + Number(response.headers.get('Retry-After')) * 1000; + } else if (result.next_allowed_at) { + smtpNextAllowedAt = Date.parse(result.next_allowed_at); + } + } catch (error) { + showSmtpStatus(`SMTP validation failed: ${error.message}`, true); + } + updateSmtpTimer(); +}); + loadAdminState().catch((error) => { showAdminState(false); adminAuthNotice.textContent = accessToken @@ -222,5 +321,6 @@ loadAdminState().catch((error) => { userList.textContent = ''; pluginList.textContent = ''; adminLabelList.textContent = ''; + smtpForm.reset(); }); })(); diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html index bfed1bf..5e4ea2b 100644 --- a/frontend/templates/admin.html +++ b/frontend/templates/admin.html @@ -72,6 +72,38 @@

Labels

Loading labels...
+