Added email functionality
Build LinkLog Development Image / development-image (push) Successful in 10s
Build LinkLog Development Image / development-image (push) Successful in 10s
This commit is contained in:
@@ -11,6 +11,14 @@ LINKLOG_APP_NAME=LinkLog
|
||||
LINKLOG_VERSION=0.1.0
|
||||
LINKLOG_SECRET_KEY=replace-with-a-long-random-secret
|
||||
LINKLOG_TOKEN_EXPIRY_DAYS=30
|
||||
LINKLOG_PUBLIC_URL=http://localhost:8000
|
||||
LINKLOG_SMTP_HOST=
|
||||
LINKLOG_SMTP_PORT=587
|
||||
LINKLOG_SMTP_USERNAME=
|
||||
LINKLOG_SMTP_PASSWORD=
|
||||
LINKLOG_SMTP_FROM=LinkLog <no-reply@localhost>
|
||||
LINKLOG_SMTP_USE_TLS=true
|
||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
||||
# Optional comma-separated override. Leave empty to use the built-in list.
|
||||
LINKLOG_TRACKING_PARAMS=
|
||||
# Keep the default path when using the named linklog_data volume.
|
||||
|
||||
@@ -175,10 +175,20 @@ The main configurable values are:
|
||||
| `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker |
|
||||
| `LINKLOG_DATABASE_PATH` | SQLite file path inside the container | `/app/backend/data/linklog.db` |
|
||||
| `LINKLOG_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` |
|
||||
| `LINKLOG_PUBLIC_URL` | Base URL used in email verification links | `http://localhost:8000` |
|
||||
| `LINKLOG_SMTP_HOST` | SMTP server hostname; empty disables delivery in local development | empty |
|
||||
| `LINKLOG_SMTP_PORT` | SMTP server port | `587` |
|
||||
| `LINKLOG_SMTP_USERNAME` | SMTP login username | empty |
|
||||
| `LINKLOG_SMTP_PASSWORD` | SMTP login password | empty |
|
||||
| `LINKLOG_SMTP_FROM` | Sender address for verification mail | `LinkLog <no-reply@localhost>` |
|
||||
| `LINKLOG_SMTP_USE_TLS` | Use STARTTLS for SMTP | `true` |
|
||||
| `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` |
|
||||
| `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list |
|
||||
| `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` |
|
||||
| `APP_PORT` | direct host port for FastAPI | `8000` |
|
||||
|
||||
New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent.
|
||||
|
||||
The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself.
|
||||
|
||||
Build and start the application and local Traefik proxy:
|
||||
|
||||
@@ -10,6 +10,9 @@ 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_verification import create_verification_token
|
||||
from backend.app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -39,6 +42,7 @@ def public_user(row):
|
||||
'avatar_url': row['avatar_url'],
|
||||
'bio': row['bio'],
|
||||
'created_at': row['created_at'],
|
||||
'email_verified': bool(row['email_verified']),
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +50,7 @@ def public_user(row):
|
||||
def list_users(_: dict = Depends(require_admin)):
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users ORDER BY username'
|
||||
'SELECT id, username, email, is_admin, avatar_url, bio, created_at, email_verified FROM users ORDER BY username'
|
||||
).fetchall()
|
||||
return [public_user(row) for row in rows]
|
||||
|
||||
@@ -62,8 +66,8 @@ def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)):
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
'''
|
||||
INSERT INTO users (id, username, email, password_hash, is_admin)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, username, email, password_hash, is_admin, email_verified)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
''',
|
||||
(str(uuid4()), username, email, hash_password(payload.password), int(payload.is_admin)),
|
||||
)
|
||||
@@ -74,8 +78,15 @@ def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)):
|
||||
raise
|
||||
|
||||
row = conn.execute(
|
||||
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE rowid = last_insert_rowid()'
|
||||
'SELECT id, username, email, is_admin, avatar_url, bio, created_at, email_verified FROM users WHERE rowid = last_insert_rowid()'
|
||||
).fetchone()
|
||||
token = create_verification_token(row['id'])
|
||||
verification_url = f'{settings.public_url}/api/auth/verify-email?token={token}'
|
||||
if smtp_configured():
|
||||
try:
|
||||
send_verification_email(row['email'], row['username'], verification_url)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail=f'User created but verification email could not be sent: {error}') from error
|
||||
return public_user(row)
|
||||
|
||||
|
||||
@@ -109,7 +120,7 @@ def update_user_privileges(
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE id = ?',
|
||||
'SELECT id, username, email, is_admin, avatar_url, bio, created_at, email_verified FROM users WHERE id = ?',
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return public_user(row)
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic import BaseModel
|
||||
|
||||
from backend.app.database import get_connection, init_db
|
||||
from backend.app.services.auth_service import authenticate_user
|
||||
from backend.app.services.email_verification import verify_email
|
||||
from backend.app.services.token_service import issue_token, revoke_token, validate_token
|
||||
|
||||
router = APIRouter()
|
||||
@@ -25,6 +26,8 @@ def login(payload: LoginRequest):
|
||||
user = authenticate_user(payload.username, payload.password)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail='Invalid username or password')
|
||||
if not user['email_verified']:
|
||||
raise HTTPException(status_code=403, detail='Email address is not verified')
|
||||
|
||||
token_data = issue_token(user['id'], user['username'])
|
||||
return {
|
||||
@@ -36,6 +39,13 @@ def login(payload: LoginRequest):
|
||||
}
|
||||
|
||||
|
||||
@router.get('/verify-email')
|
||||
def verify_email_address(token: str):
|
||||
if not verify_email(token):
|
||||
raise HTTPException(status_code=400, detail='Verification link is invalid or expired')
|
||||
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
||||
|
||||
|
||||
@router.post('/logout')
|
||||
def logout(payload: dict):
|
||||
token = payload.get('token')
|
||||
|
||||
@@ -17,6 +17,14 @@ class Settings:
|
||||
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}')
|
||||
secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me')
|
||||
token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30'))
|
||||
public_url: str = os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000').rstrip('/')
|
||||
smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '')
|
||||
smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587'))
|
||||
smtp_username: str = os.getenv('LINKLOG_SMTP_USERNAME', '')
|
||||
smtp_password: str = os.getenv('LINKLOG_SMTP_PASSWORD', '')
|
||||
smtp_from: str = os.getenv('LINKLOG_SMTP_FROM', 'LinkLog <no-reply@localhost>')
|
||||
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
|
||||
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
|
||||
tracking_params: list[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
|
||||
@@ -101,6 +101,21 @@ UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%';
|
||||
(4, '''
|
||||
ALTER TABLE tags ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_created_by ON tags(created_by);
|
||||
'''),
|
||||
(5, '''
|
||||
ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE users SET email_verified = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id
|
||||
ON email_verification_tokens(user_id);
|
||||
'''),
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from email.message import EmailMessage
|
||||
from smtplib import SMTP
|
||||
|
||||
from backend.app.core.config import settings
|
||||
|
||||
|
||||
def smtp_configured() -> bool:
|
||||
return bool(settings.smtp_host and settings.smtp_from)
|
||||
|
||||
|
||||
def send_verification_email(email: str, username: str, verification_url: str) -> None:
|
||||
if not smtp_configured():
|
||||
raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM')
|
||||
|
||||
message = EmailMessage()
|
||||
message['Subject'] = 'Verify your LinkLog email address'
|
||||
message['From'] = settings.smtp_from
|
||||
message['To'] = email
|
||||
message.set_content(
|
||||
f'Hello {username},\n\n'
|
||||
f'Verify your LinkLog email address by opening this link:\n{verification_url}\n\n'
|
||||
f'This link expires in {settings.email_verification_expiry_hours} hours.\n'
|
||||
)
|
||||
|
||||
with SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||
if settings.smtp_use_tls:
|
||||
smtp.starttls()
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||
smtp.send_message(message)
|
||||
@@ -0,0 +1,44 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.database import get_connection
|
||||
|
||||
|
||||
def hash_verification_token(token: str) -> str:
|
||||
return sha256(token.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def create_verification_token(user_id: str) -> str:
|
||||
token = token_urlsafe(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_expiry_hours)
|
||||
with get_connection() as conn:
|
||||
conn.execute('DELETE FROM email_verification_tokens WHERE user_id = ?', (user_id,))
|
||||
conn.execute(
|
||||
'''INSERT INTO email_verification_tokens
|
||||
(id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)''',
|
||||
(str(uuid4()), user_id, hash_verification_token(token), expires_at.isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
return token
|
||||
|
||||
|
||||
def verify_email(token: str) -> bool:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
'''SELECT user_id FROM email_verification_tokens
|
||||
WHERE token_hash = ? AND expires_at > ?''',
|
||||
(hash_verification_token(token), now),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
conn.execute('UPDATE users SET email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (row['user_id'],))
|
||||
conn.execute('DELETE FROM email_verification_tokens WHERE user_id = ?', (row['user_id'],))
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -9,6 +9,7 @@ from uuid import uuid4
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.app.main import app
|
||||
from backend.app.services.token_service import issue_token
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -74,6 +75,41 @@ def test_admin_can_add_list_and_remove_users():
|
||||
assert client.put('/api/admin/users/user-1', headers=headers, json={'is_admin': False}).status_code == 400
|
||||
|
||||
|
||||
def test_new_user_must_verify_email_before_login():
|
||||
headers = login_headers()
|
||||
username = f'unverified-{uuid4().hex}'
|
||||
created = client.post('/api/admin/users', headers=headers, json={
|
||||
'username': username,
|
||||
'email': f'{username}@example.com',
|
||||
'password': 'secret123',
|
||||
})
|
||||
assert created.status_code == 201
|
||||
assert created.json()['email_verified'] is False
|
||||
|
||||
login = client.post('/api/auth/login', json={'username': username, 'password': 'secret123'})
|
||||
assert login.status_code == 403
|
||||
assert login.json()['detail'] == 'Email address is not verified'
|
||||
|
||||
|
||||
def test_email_verification_link_enables_login():
|
||||
headers = login_headers()
|
||||
username = f'verifiable-{uuid4().hex}'
|
||||
created = client.post('/api/admin/users', headers=headers, json={
|
||||
'username': username,
|
||||
'email': f'{username}@example.com',
|
||||
'password': 'secret123',
|
||||
})
|
||||
assert created.status_code == 201
|
||||
user_id = created.json()['id']
|
||||
from backend.app.services.email_verification import create_verification_token
|
||||
verification_token = create_verification_token(user_id)
|
||||
|
||||
verified = client.get('/api/auth/verify-email', params={'token': verification_token})
|
||||
assert verified.status_code == 200
|
||||
assert client.post('/api/auth/login', json={'username': username, 'password': 'secret123'}).status_code == 200
|
||||
assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400
|
||||
|
||||
|
||||
def test_admin_can_remove_user_with_owned_data():
|
||||
headers = login_headers()
|
||||
username = f'data-owner-{uuid4().hex}'
|
||||
@@ -84,7 +120,8 @@ def test_admin_can_remove_user_with_owned_data():
|
||||
})
|
||||
assert create_response.status_code == 201
|
||||
user_id = create_response.json()['id']
|
||||
user_headers = login_headers(username)
|
||||
user_token = issue_token(user_id, username)['access_token']
|
||||
user_headers = {'Authorization': f'Bearer {user_token}'}
|
||||
|
||||
link_response = client.post('/api/links', headers=user_headers, json={
|
||||
'title': 'Owned link',
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 4
|
||||
assert get_schema_version(connection) == 5
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 4
|
||||
assert get_schema_version(connection) == 5
|
||||
|
||||
connection.close()
|
||||
@@ -0,0 +1,28 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.app.services.email_service import send_verification_email
|
||||
|
||||
|
||||
def test_send_verification_email_uses_smtp_settings(monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, 'smtp_host', 'smtp.example.com')
|
||||
monkeypatch.setattr(settings, 'smtp_port', 587)
|
||||
monkeypatch.setattr(settings, 'smtp_from', 'LinkLog <no-reply@example.com>')
|
||||
monkeypatch.setattr(settings, 'smtp_username', 'mailer')
|
||||
monkeypatch.setattr(settings, 'smtp_password', 'secret')
|
||||
monkeypatch.setattr(settings, 'smtp_use_tls', True)
|
||||
|
||||
with patch('backend.app.services.email_service.SMTP') as smtp_class:
|
||||
smtp = smtp_class.return_value.__enter__.return_value
|
||||
send_verification_email('user@example.com', 'user', 'https://linklog.example/verify')
|
||||
|
||||
smtp_class.assert_called_once_with('smtp.example.com', 587, timeout=10)
|
||||
smtp.starttls.assert_called_once_with()
|
||||
smtp.login.assert_called_once_with('mailer', 'secret')
|
||||
message = smtp.send_message.call_args.args[0]
|
||||
assert message['To'] == 'user@example.com'
|
||||
assert 'https://linklog.example/verify' in message.get_content()
|
||||
@@ -14,6 +14,14 @@ services:
|
||||
LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db}
|
||||
LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env}
|
||||
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
|
||||
LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-https://linklog.example.com}
|
||||
LINKLOG_SMTP_HOST: ${LINKLOG_SMTP_HOST:-smtp.example.com}
|
||||
LINKLOG_SMTP_PORT: ${LINKLOG_SMTP_PORT:-587}
|
||||
LINKLOG_SMTP_USERNAME: ${LINKLOG_SMTP_USERNAME:?Set LINKLOG_SMTP_USERNAME in .env}
|
||||
LINKLOG_SMTP_PASSWORD: ${LINKLOG_SMTP_PASSWORD:?Set LINKLOG_SMTP_PASSWORD in .env}
|
||||
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@example.com>}
|
||||
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||
healthcheck:
|
||||
|
||||
@@ -17,6 +17,14 @@ services:
|
||||
LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db}
|
||||
LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env}
|
||||
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
|
||||
LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-http://localhost:8000}
|
||||
LINKLOG_SMTP_HOST: ${LINKLOG_SMTP_HOST:-}
|
||||
LINKLOG_SMTP_PORT: ${LINKLOG_SMTP_PORT:-587}
|
||||
LINKLOG_SMTP_USERNAME: ${LINKLOG_SMTP_USERNAME:-}
|
||||
LINKLOG_SMTP_PASSWORD: ${LINKLOG_SMTP_PASSWORD:-}
|
||||
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@localhost>}
|
||||
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||
healthcheck:
|
||||
|
||||
Reference in New Issue
Block a user