diff --git a/.env.example b/.env.example
index 98ac7d10..6d9b21b3 100644
--- a/.env.example
+++ b/.env.example
@@ -84,3 +84,12 @@ OPENAI_API_KEY=""
KAAPI_GUARDRAILS_AUTH=""
KAAPI_GUARDRAILS_URL=""
+
+SMTP_HOST=
+SMTP_PORT=
+SMTP_TLS=True
+SMTP_USER=
+SMTP_PASSWORD=
+EMAILS_FROM_EMAIL=
+EMAILS_FROM_NAME=Kaapi
+FRONTEND_HOST=
diff --git a/backend/app/api/docs/auth/invite_verify.md b/backend/app/api/docs/auth/invite_verify.md
new file mode 100644
index 00000000..aaf397b0
--- /dev/null
+++ b/backend/app/api/docs/auth/invite_verify.md
@@ -0,0 +1,20 @@
+# Verify Invitation
+
+Verify an invitation token from a magic link email and log the user in.
+
+## Query Parameters
+
+- **token** (required): The invitation JWT token from the email link.
+
+## Behavior
+
+1. Validates the invitation token (checks signature, expiry, and type).
+2. Looks up the user by the email embedded in the token.
+3. If the user exists and is inactive (first login), activates the account.
+4. Returns a JWT access token with the org/project from the invitation embedded.
+5. Sets `access_token` and `refresh_token` as HTTP-only cookies.
+
+## Error Responses
+
+- **400**: Invalid or expired invitation link.
+- **404**: User account not found.
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
index a7a7465b..f77e2192 100644
--- a/backend/app/api/routes/auth.py
+++ b/backend/app/api/routes/auth.py
@@ -21,6 +21,7 @@
build_token_response,
clear_auth_cookies,
validate_refresh_token,
+ verify_invite_token,
)
from app.utils import APIResponse, load_description
@@ -198,3 +199,47 @@ def logout() -> JSONResponse:
response = JSONResponse(content=api_response.model_dump())
clear_auth_cookies(response)
return response
+
+
+@router.get(
+ "/invite/verify",
+ description=load_description("auth/invite_verify.md"),
+ response_model=APIResponse[Token],
+)
+def verify_invitation(session: SessionDep, token: str) -> JSONResponse:
+ """Verify an invitation token, activate the user, and log them in."""
+
+ invite_data = verify_invite_token(token)
+ if not invite_data:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid or expired invitation link",
+ )
+
+ user = get_user_by_email(session=session, email=invite_data["email"])
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User account not found. Please contact support.",
+ )
+
+ # Activate user if not already active
+ if not user.is_active:
+ user.is_active = True
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+ logger.info(
+ f"[verify_invitation] User activated via invite | user_id: {user.id}"
+ )
+
+ response = build_token_response(
+ user_id=user.id,
+ organization_id=invite_data["organization_id"],
+ project_id=invite_data["project_id"],
+ )
+
+ logger.info(
+ f"[verify_invitation] Invitation verified | user_id: {user.id}, project_id: {invite_data['project_id']}"
+ )
+ return response
diff --git a/backend/app/api/routes/user_project.py b/backend/app/api/routes/user_project.py
index 5052761f..cd0a6284 100644
--- a/backend/app/api/routes/user_project.py
+++ b/backend/app/api/routes/user_project.py
@@ -5,6 +5,9 @@
from app.api.deps import AuthContextDep, SessionDep
from app.api.permissions import Permission, require_permission
+from app.core.config import settings
+from app.crud.organization import get_organization_by_id
+from app.crud.project import get_project_by_id
from app.crud.user_project import (
add_user_to_project,
get_users_by_project,
@@ -15,7 +18,13 @@
Message,
UserProjectPublic,
)
-from app.utils import APIResponse, load_description
+from app.services.auth import generate_invite_token
+from app.utils import (
+ APIResponse,
+ generate_invite_email,
+ load_description,
+ send_email,
+)
logger = logging.getLogger(__name__)
@@ -83,6 +92,37 @@ def add_project_users(
session.commit()
+ # Send invitation emails
+ organization = get_organization_by_id(session=session, org_id=body.organization_id)
+ project = get_project_by_id(session=session, project_id=body.project_id)
+
+ if settings.emails_enabled and organization and project:
+ for entry in body.users:
+ try:
+ invite_token = generate_invite_token(
+ email=str(entry.email),
+ organization_id=body.organization_id,
+ project_id=body.project_id,
+ )
+ email_data = generate_invite_email(
+ email_to=str(entry.email),
+ project_name=project.name,
+ organization_name=organization.name,
+ invite_token=invite_token,
+ )
+ send_email(
+ email_to=str(entry.email),
+ subject=email_data.subject,
+ html_content=email_data.html_content,
+ )
+ logger.info(
+ f"[add_project_users] Invitation email sent | email: {entry.email}"
+ )
+ except Exception as e:
+ logger.error(
+ f"[add_project_users] Failed to send invitation email | email: {entry.email}, error: {e}"
+ )
+
# Re-fetch all users for this project to return the full list
results = get_users_by_project(session=session, project_id=body.project_id)
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 49cf7e5f..3694e3c8 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -57,6 +57,27 @@ class Settings(BaseSettings):
# Google OAuth
GOOGLE_CLIENT_ID: str = ""
+ # Frontend URL for magic links
+ FRONTEND_HOST: str = "http://localhost:3000"
+
+ # Invitation token expiry (default 7 days)
+ INVITE_TOKEN_EXPIRE_HOURS: int = 24 * 7
+
+ # SMTP / Email
+ SMTP_HOST: str = ""
+ SMTP_PORT: int = 587
+ SMTP_USER: str = ""
+ SMTP_PASSWORD: str = ""
+ SMTP_TLS: bool = True
+ SMTP_SSL: bool = False
+ EMAILS_FROM_EMAIL: str = ""
+ EMAILS_FROM_NAME: str = ""
+
+ @computed_field # type: ignore[prop-decorator]
+ @property
+ def emails_enabled(self) -> bool:
+ return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
+
@computed_field # type: ignore[prop-decorator]
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
diff --git a/backend/app/email-templates/build/invite_user.html b/backend/app/email-templates/build/invite_user.html
new file mode 100644
index 00000000..1b7da47e
--- /dev/null
+++ b/backend/app/email-templates/build/invite_user.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ You're invited to {{ project_name }}
+
+
+
+
+
+
+
+
+
+ Kaapi Konsole
+
+
+ {{ organization_name }}
+
+
+
+ You have been invited to join the {{ project_name }} project on {{ app_name }}.
+
+
+ Click the button below to accept the invitation and get started.
+
+
+
+ This invitation expires in {{ valid_days }} days.
+ If you did not expect this invitation, you can safely ignore this email.
+
+ |
+
+
+ |
+
+
+
+
diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py
index 5c266092..594f68da 100644
--- a/backend/app/services/auth.py
+++ b/backend/app/services/auth.py
@@ -1,5 +1,5 @@
import logging
-from datetime import timedelta
+from datetime import datetime, timedelta, timezone
import jwt as pyjwt
from fastapi import HTTPException, status
@@ -176,3 +176,44 @@ def validate_refresh_token(
raise HTTPException(status_code=403, detail="Inactive user")
return user, token_data
+
+
+def generate_invite_token(
+ email: str,
+ organization_id: int,
+ project_id: int,
+) -> str:
+ """Generate a JWT invitation token for a user."""
+ delta = timedelta(hours=settings.INVITE_TOKEN_EXPIRE_HOURS)
+ now = datetime.now(timezone.utc)
+ expires = now + delta
+ to_encode = {
+ "exp": expires.timestamp(),
+ "nbf": now,
+ "sub": email,
+ "org_id": organization_id,
+ "project_id": project_id,
+ "type": "invite",
+ }
+ return pyjwt.encode(to_encode, settings.SECRET_KEY, algorithm=security.ALGORITHM)
+
+
+def verify_invite_token(token: str) -> dict | None:
+ """
+ Verify an invitation token and return the payload.
+
+ Returns dict with email, org_id, project_id or None if invalid.
+ """
+ try:
+ payload = pyjwt.decode(
+ token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
+ )
+ if payload.get("type") != "invite":
+ return None
+ return {
+ "email": payload["sub"],
+ "organization_id": payload["org_id"],
+ "project_id": payload["project_id"],
+ }
+ except (InvalidTokenError, KeyError):
+ return None
diff --git a/backend/app/tests/api/test_auth.py b/backend/app/tests/api/test_auth.py
index 1ecafcd3..81beeb6e 100644
--- a/backend/app/tests/api/test_auth.py
+++ b/backend/app/tests/api/test_auth.py
@@ -6,6 +6,10 @@
from app.core.config import settings
from app.core.security import create_access_token, create_refresh_token
+from app.services.auth import (
+ generate_invite_token,
+ verify_invite_token,
+)
from app.tests.utils.auth import TestAuthContext
from app.tests.utils.user import create_random_user
@@ -13,6 +17,7 @@
SELECT_PROJECT_URL = f"{settings.API_V1_STR}/auth/select-project"
REFRESH_URL = f"{settings.API_V1_STR}/auth/refresh"
LOGOUT_URL = f"{settings.API_V1_STR}/auth/logout"
+INVITE_VERIFY_URL = f"{settings.API_V1_STR}/auth/invite/verify"
MOCK_GOOGLE_PROFILE = {
"email": None, # set per test
@@ -107,9 +112,6 @@ def test_google_auth_activates_inactive_user(
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
assert resp.status_code == 200
- db.refresh(user)
- assert user.is_active is True
-
@patch("app.api.routes.auth.id_token.verify_oauth2_token")
@patch("app.api.routes.auth.settings")
def test_google_auth_success_no_projects(
@@ -298,3 +300,75 @@ def test_logout_success(self, client: TestClient):
body = resp.json()
assert body["success"] is True
assert body["data"]["message"] == "Logged out successfully"
+
+
+class TestInviteVerify:
+ """Test suite for GET /auth/invite/verify endpoint."""
+
+ def test_verify_invalid_token(self, client: TestClient):
+ """Test returns 400 for invalid invite token."""
+ resp = client.get(f"{INVITE_VERIFY_URL}?token=invalid.token")
+ assert resp.status_code == 400
+
+ def test_verify_user_not_found(self, client: TestClient):
+ """Test returns 404 when invited user doesn't exist."""
+ token = generate_invite_token(
+ email="ghost@example.com", organization_id=1, project_id=1
+ )
+ resp = client.get(f"{INVITE_VERIFY_URL}?token={token}")
+ assert resp.status_code == 404
+
+ def test_verify_activates_inactive_user(
+ self, db: Session, client: TestClient, user_api_key: TestAuthContext
+ ):
+ """Test invite verification activates inactive user."""
+ user = create_random_user(db)
+ user.is_active = False
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+
+ token = generate_invite_token(
+ email=user.email,
+ organization_id=user_api_key.organization.id,
+ project_id=user_api_key.project.id,
+ )
+ resp = client.get(f"{INVITE_VERIFY_URL}?token={token}")
+ assert resp.status_code == 200
+
+ db.refresh(user)
+ assert user.is_active is True
+ assert "access_token" in resp.json()["data"]
+
+ def test_verify_success_active_user(
+ self, db: Session, client: TestClient, user_api_key: TestAuthContext
+ ):
+ """Test invite verification works for already active user."""
+ user = create_random_user(db)
+ token = generate_invite_token(
+ email=user.email,
+ organization_id=user_api_key.organization.id,
+ project_id=user_api_key.project.id,
+ )
+ resp = client.get(f"{INVITE_VERIFY_URL}?token={token}")
+ assert resp.status_code == 200
+ assert "access_token" in resp.json()["data"]
+
+
+class TestTokenGeneration:
+ """Test suite for services/auth.py token generation functions."""
+
+ def test_generate_and_verify_invite_token(self):
+ """Test invite token roundtrip."""
+ token = generate_invite_token(
+ email="test@example.com", organization_id=1, project_id=2
+ )
+ result = verify_invite_token(token)
+ assert result is not None
+ assert result["email"] == "test@example.com"
+ assert result["organization_id"] == 1
+ assert result["project_id"] == 2
+
+ def test_verify_invite_token_invalid(self):
+ """Test invite verify returns None for garbage tokens."""
+ assert verify_invite_token("garbage") is None
diff --git a/backend/app/tests/api/test_user_project.py b/backend/app/tests/api/test_user_project.py
index b984fd06..dd4f6cce 100644
--- a/backend/app/tests/api/test_user_project.py
+++ b/backend/app/tests/api/test_user_project.py
@@ -1,3 +1,5 @@
+from unittest.mock import patch
+
from fastapi.testclient import TestClient
from sqlmodel import Session
@@ -101,6 +103,38 @@ def test_add_single_user(
emails = [u["email"] for u in data]
assert email in emails
+ @patch("app.api.routes.user_project.send_email")
+ @patch("app.api.routes.user_project.settings")
+ def test_add_user_sends_invite_email(
+ self,
+ mock_settings,
+ mock_send_email,
+ db: Session,
+ client: TestClient,
+ superuser_token_headers: dict[str, str],
+ ):
+ """Test adding a user sends an invitation email when emails are enabled."""
+ project = create_test_project(db)
+ email = random_email()
+
+ mock_settings.emails_enabled = True
+ mock_settings.INVITE_TOKEN_EXPIRE_HOURS = 168
+ mock_settings.SECRET_KEY = settings.SECRET_KEY
+ mock_settings.FRONTEND_HOST = "http://localhost:3000"
+ mock_settings.PROJECT_NAME = "Kaapi"
+
+ resp = client.post(
+ f"{USER_PROJECTS_URL}/",
+ json={
+ "organization_id": project.organization_id,
+ "project_id": project.id,
+ "users": [{"email": email}],
+ },
+ headers=superuser_token_headers,
+ )
+ assert resp.status_code == 201
+ mock_send_email.assert_called_once()
+
def test_add_duplicate_user_same_project(
self,
db: Session,
diff --git a/backend/app/utils.py b/backend/app/utils.py
index 353be215..89ea857a 100644
--- a/backend/app/utils.py
+++ b/backend/app/utils.py
@@ -184,6 +184,29 @@ def generate_new_account_email(
return EmailData(html_content=html_content, subject=subject)
+def generate_invite_email(
+ *,
+ email_to: str,
+ project_name: str,
+ organization_name: str,
+ invite_token: str,
+) -> EmailData:
+ app_name = settings.PROJECT_NAME
+ subject = f"{app_name} - You've been invited to {project_name}"
+ link = f"{settings.FRONTEND_HOST}/invite?token={invite_token}"
+ html_content = render_email_template(
+ template_name="invite_user.html",
+ context={
+ "app_name": app_name,
+ "project_name": project_name,
+ "organization_name": organization_name,
+ "link": link,
+ "valid_days": settings.INVITE_TOKEN_EXPIRE_HOURS // 24,
+ },
+ )
+ return EmailData(html_content=html_content, subject=subject)
+
+
def generate_password_reset_token(email: str) -> str:
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
now = datetime.now(timezone.utc)