added backends, improved templating, rbac

This commit is contained in:
2026-06-10 14:40:22 +02:00
parent d9ca48addc
commit ce43f2658f
28 changed files with 1183 additions and 78 deletions

19
.gitignore vendored
View File

@@ -129,13 +129,13 @@ celerybeat.pid
*.sage.py *.sage.py
# Environments # Environments
.env .env*
.venv .venv*
env/ env*/
venv/ venv*/
ENV/ ENV*/
env.bak/ env*.bak/
venv.bak/ venv*.bak/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
@@ -176,3 +176,8 @@ cython_debug/
.vscode .vscode
.vscode/** .vscode/**
runtime
runtime/
server/*.db

View File

@@ -922,3 +922,28 @@ curl -X POST 'http://127.0.0.1:8000/api/v1/campaigns/<campaign-id>/versions/<ver
``` ```
Strict validation/build/send endpoints are unchanged. The WebUI should use partial validation while editing, and only call strict validation/build when the user reaches Review/Send. Strict validation/build/send endpoints are unchanged. The WebUI should use partial validation while editing, and only call strict validation/build when the user reaches Review/Send.
## Browser login / session auth
The backend now supports both automation API keys and browser session tokens.
Development login after `init_db --with-dev-data` or dev bootstrap:
```text
Tenant: default
Email: admin@example.local
Password: dev-admin
```
Login endpoint:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"tenant_slug":"default","email":"admin@example.local","password":"dev-admin"}'
```
Use the returned `access_token` as `Authorization: Bearer <token>`. Existing API keys still work via `X-API-Key`.
RBAC scaffolding now includes users, groups, roles, direct user-role assignments, group-role assignments and login sessions. The development user receives the `owner` role.

View File

@@ -0,0 +1,130 @@
"""auth sessions and RBAC assignments
Revision ID: 2c3d4e5f6a7b
Revises: 1f8d4c2a0b7e
Create Date: 2026-06-08 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2c3d4e5f6a7b"
down_revision = "1f8d4c2a0b7e"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("auth_provider", sa.String(length=50), nullable=False, server_default="local"))
batch_op.add_column(sa.Column("password_hash", sa.String(length=500), nullable=True))
batch_op.add_column(sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
op.create_table(
"user_group_memberships",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("group_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),
)
op.create_index(op.f("ix_user_group_memberships_tenant_id"), "user_group_memberships", ["tenant_id"])
op.create_index(op.f("ix_user_group_memberships_user_id"), "user_group_memberships", ["user_id"])
op.create_index(op.f("ix_user_group_memberships_group_id"), "user_group_memberships", ["group_id"])
op.create_table(
"user_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),
)
op.create_index(op.f("ix_user_role_assignments_tenant_id"), "user_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_user_role_assignments_user_id"), "user_role_assignments", ["user_id"])
op.create_index(op.f("ix_user_role_assignments_role_id"), "user_role_assignments", ["role_id"])
op.create_table(
"group_role_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("group_id", sa.String(length=36), nullable=False),
sa.Column("role_id", sa.String(length=36), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["group_id"], ["groups.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),
)
op.create_index(op.f("ix_group_role_assignments_tenant_id"), "group_role_assignments", ["tenant_id"])
op.create_index(op.f("ix_group_role_assignments_group_id"), "group_role_assignments", ["group_id"])
op.create_index(op.f("ix_group_role_assignments_role_id"), "group_role_assignments", ["role_id"])
op.create_table(
"auth_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.String(length=128), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("ip_address", sa.String(length=100), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index(op.f("ix_auth_sessions_tenant_id"), "auth_sessions", ["tenant_id"])
op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"])
op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"])
op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"])
op.create_index(op.f("ix_auth_sessions_revoked_at"), "auth_sessions", ["revoked_at"])
def downgrade() -> None:
op.drop_index(op.f("ix_auth_sessions_revoked_at"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
op.drop_index(op.f("ix_auth_sessions_tenant_id"), table_name="auth_sessions")
op.drop_table("auth_sessions")
op.drop_index(op.f("ix_group_role_assignments_role_id"), table_name="group_role_assignments")
op.drop_index(op.f("ix_group_role_assignments_group_id"), table_name="group_role_assignments")
op.drop_index(op.f("ix_group_role_assignments_tenant_id"), table_name="group_role_assignments")
op.drop_table("group_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_role_id"), table_name="user_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_user_id"), table_name="user_role_assignments")
op.drop_index(op.f("ix_user_role_assignments_tenant_id"), table_name="user_role_assignments")
op.drop_table("user_role_assignments")
op.drop_index(op.f("ix_user_group_memberships_group_id"), table_name="user_group_memberships")
op.drop_index(op.f("ix_user_group_memberships_user_id"), table_name="user_group_memberships")
op.drop_index(op.f("ix_user_group_memberships_tenant_id"), table_name="user_group_memberships")
op.drop_table("user_group_memberships")
with op.batch_alter_table("users") as batch_op:
batch_op.drop_column("last_login_at")
batch_op.drop_column("password_hash")
batch_op.drop_column("auth_provider")

View File

@@ -1,12 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from .admin import router as admin_router from .admin import router as admin_router
from .auth import router as auth_router
from .campaigns import router as campaigns_router from .campaigns import router as campaigns_router
from .audit import router as audit_router from .audit import router as audit_router
from .system import router as system_router from .system import router as system_router
from .mail import router as mail_router
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(campaigns_router) router.include_router(campaigns_router)
router.include_router(admin_router) router.include_router(admin_router)
router.include_router(audit_router) router.include_router(audit_router)
router.include_router(system_router) router.include_router(system_router)
router.include_router(mail_router)

128
server/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.v1.schemas import GroupInfo, LoginRequest, LoginResponse, MeResponse, RoleInfo, TenantInfo, TenantMembershipInfo, UserInfo
from app.auth.dependencies import ApiPrincipal, get_api_principal
from app.db.models import Tenant, User
from app.db.session import get_session
from app.security.passwords import verify_password
from app.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes, create_auth_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
def _tenant_info(tenant: Tenant) -> TenantInfo:
return TenantInfo(id=tenant.id, slug=tenant.slug, name=tenant.name)
def _user_info(user: User) -> UserInfo:
return UserInfo(id=user.id, email=user.email, display_name=user.display_name, is_tenant_admin=user.is_tenant_admin)
def _roles_info(roles) -> list[RoleInfo]:
return [RoleInfo(id=r.id, slug=r.slug, name=r.name, permissions=r.permissions or []) for r in roles]
def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=g.id, slug=g.slug, name=g.name) for g in groups]
def _tenant_memberships_for_email(session: Session, email: str) -> list[TenantMembershipInfo]:
"""Return tenants that currently contain an active user with this email.
The current data model still stores users inside one tenant. Until a dedicated
cross-tenant identity table exists, matching by email is the lightweight bridge
that lets the frontend render tenant context and later expose switching only
when more than one membership exists.
"""
rows = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == email, User.is_active.is_(True), Tenant.is_active.is_(True))
.order_by(Tenant.name.asc())
.all()
)
memberships: list[TenantMembershipInfo] = []
for user, tenant in rows:
roles = collect_user_roles(session, user)
memberships.append(
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
roles=[role.slug for role in roles],
is_active=True,
)
)
return memberships
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[User, Tenant]:
query = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == payload.email, User.is_active.is_(True), Tenant.is_active.is_(True))
)
if payload.tenant_slug:
query = query.filter(Tenant.slug == payload.tenant_slug)
rows = query.order_by(Tenant.name.asc()).all()
for user, tenant in rows:
if user.password_hash and verify_password(payload.password, user.password_hash):
return user, tenant
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, session: Session = Depends(get_session)):
user, tenant = _resolve_login_user(session, payload)
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
created = create_auth_session(session, user=user, user_agent=user_agent, ip_address=ip_address)
roles = collect_user_roles(session, user)
groups = collect_user_groups(session, user)
scopes = collect_user_scopes(session, user)
tenants = _tenant_memberships_for_email(session, user.email)
session.commit()
active_tenant = _tenant_info(tenant)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
user=_user_info(user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=tenants,
scopes=scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
roles = collect_user_roles(session, principal.user)
groups = collect_user_groups(session, principal.user)
active_tenant = _tenant_info(tenant)
return MeResponse(
user=_user_info(principal.user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=_tenant_memberships_for_email(session, principal.user.email),
scopes=principal.scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.post("/logout")
def logout(request: Request, session: Session = Depends(get_session)):
authorization = request.headers.get("authorization") or ""
if authorization.lower().startswith("bearer "):
revoke_auth_session(session, authorization[7:].strip())
session.commit()
return {"ok": True}

116
server/app/api/v1/mail.py Normal file
View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.api.v1.schemas import (
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestRequest,
MailSmtpTestRequest,
)
from app.auth.dependencies import ApiPrincipal, require_scope
from app.mailer.sending.imap import list_imap_folders, test_imap_login
from app.mailer.sending.smtp import test_smtp_login
router = APIRouter(prefix="/mail", tags=["mail"])
def _safe_error_message(exc: Exception) -> str:
text = str(exc).strip()
return text or exc.__class__.__name__
@router.post("/test-smtp", response_model=MailConnectionTestResponse)
def test_smtp_settings(
payload: MailSmtpTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Test SMTP connectivity/login without sending any message."""
del principal
try:
result = test_smtp_login(smtp_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="smtp",
host=result.host,
port=result.port,
security=result.security,
message="SMTP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="smtp",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/test-imap", response_model=MailConnectionTestResponse)
def test_imap_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Test IMAP connectivity/login without selecting or appending messages."""
del principal
try:
result = test_imap_login(imap_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="imap",
host=result.host,
port=result.port,
security=result.security,
message="IMAP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="imap",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/list-imap-folders", response_model=MailImapFolderListResponse)
def list_imap_folder_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""List visible IMAP folders and return the best Sent-folder guess."""
del principal
try:
result = list_imap_folders(imap_config=payload)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
return MailImapFolderListResponse(
ok=True,
host=result.host,
port=result.port,
security=result.security,
message=f"Found {len(folders)} IMAP folder(s).",
folders=folders,
detected_sent_folder=result.detected_sent_folder,
)
except Exception as exc:
return MailImapFolderListResponse(
ok=False,
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
folders=[],
detected_sent_folder=None,
details={"error_type": exc.__class__.__name__},
)

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.mailer.campaign.models import ImapConfig, SmtpConfig
class CampaignCreateRequest(BaseModel): class CampaignCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -126,6 +128,43 @@ class BuildCampaignRequest(BaseModel):
write_eml: bool = True write_eml: bool = True
class MailSmtpTestRequest(SmtpConfig):
"""SMTP settings supplied directly from the WebUI mail settings form."""
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
host: str | None = None
port: int | None = None
security: str | None = None
message: str
details: dict[str, Any] = Field(default_factory=dict)
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
class MailImapFolderListResponse(BaseModel):
ok: bool
protocol: Literal["imap"] = "imap"
host: str | None = None
port: int | None = None
security: str | None = None
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class ApiKeyCreateRequest(BaseModel): class ApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -200,3 +239,70 @@ class AuditLogItemResponse(BaseModel):
class AuditLogListResponse(BaseModel): class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse] items: list[AuditLogItemResponse]
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
password: str
# Kept optional for backwards compatibility and future tenant-switch login flows.
# The WebUI no longer sends it. If omitted, the backend resolves the user by email.
tenant_slug: str | None = None
class TenantInfo(BaseModel):
id: str
slug: str
name: str
class TenantMembershipInfo(TenantInfo):
roles: list[str] = Field(default_factory=list)
is_active: bool = True
class UserInfo(BaseModel):
id: str
email: str
display_name: str | None = None
is_tenant_admin: bool = False
class RoleInfo(BaseModel):
id: str
slug: str
name: str
permissions: list[str] = Field(default_factory=list)
class GroupInfo(BaseModel):
id: str
slug: str
name: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
class MeResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)

View File

@@ -82,7 +82,7 @@ def audit_from_principal(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
user_id=principal.user.id, user_id=principal.user.id,
api_key_id=principal.api_key.id, api_key_id=principal.api_key.id if principal.api_key else None,
action=action, action=action,
object_type=object_type, object_type=object_type,
object_id=object_id, object_id=object_id,

View File

@@ -5,24 +5,27 @@ from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, status from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import ApiKey, User from app.db.models import ApiKey, AuthSession, User
from app.db.session import get_session from app.db.session import get_session
from app.security.api_keys import authenticate_api_key, has_scope from app.security.api_keys import authenticate_api_key, has_scope as api_key_has_scope
from app.security.sessions import authenticate_session_token, collect_user_scopes
@dataclass(slots=True) @dataclass(slots=True)
class ApiPrincipal: class ApiPrincipal:
api_key: ApiKey
user: User user: User
tenant_id: str tenant_id: str
scopes: list[str]
api_key: ApiKey | None = None
auth_session: AuthSession | None = None
def _extract_api_key(authorization: str | None, x_api_key: str | None) -> str | None: def _extract_token(authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
if x_api_key: if x_api_key:
return x_api_key.strip() return x_api_key.strip(), "api_key"
if authorization and authorization.lower().startswith("bearer "): if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip() return authorization[7:].strip(), "bearer"
return None return None, "none"
def get_api_principal( def get_api_principal(
@@ -30,22 +33,41 @@ def get_api_principal(
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal: ) -> ApiPrincipal:
secret = _extract_api_key(authorization, x_api_key) token, source = _extract_token(authorization, x_api_key)
if not secret: if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
api_key = authenticate_api_key(session, secret)
if not api_key: # API keys remain supported for CLI/automation. Browser login uses session tokens.
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") api_key = authenticate_api_key(session, token)
if api_key:
user = session.get(User, api_key.user_id) user = session.get(User, api_key.user_id)
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
session.commit() session.commit()
return ApiPrincipal(api_key=api_key, user=user, tenant_id=api_key.tenant_id) return ApiPrincipal(api_key=api_key, user=user, tenant_id=api_key.tenant_id, scopes=api_key.scopes or [])
auth_session = authenticate_session_token(session, token)
if auth_session:
user = session.get(User, auth_session.user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
scopes = collect_user_scopes(session, user)
session.commit()
return ApiPrincipal(auth_session=auth_session, user=user, tenant_id=user.tenant_id, scopes=scopes)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def has_scope(principal: ApiPrincipal, required_scope: str) -> bool:
scopes = set(principal.scopes or [])
if principal.api_key:
return api_key_has_scope(principal.api_key, required_scope)
return "*" in scopes or required_scope in scopes
def require_scope(required_scope: str): def require_scope(required_scope: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not has_scope(principal.api_key, required_scope): if not has_scope(principal, required_scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}")
return principal return principal

View File

@@ -5,9 +5,10 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.base import Base from app.db.base import Base
from app.db.models import Role, Tenant, User from app.db.models import Role, Tenant, User, UserRoleAssignment
from app.db.session import engine from app.db.session import engine
from app.security.api_keys import CreatedApiKey, create_api_key from app.security.api_keys import CreatedApiKey, create_api_key
from app.security.passwords import hash_password
DEFAULT_SCOPES = [ DEFAULT_SCOPES = [
"campaign:read", "campaign:read",
@@ -62,6 +63,7 @@ def bootstrap_dev_data(
api_key_secret: str | None = None, api_key_secret: str | None = None,
tenant_slug: str = "default", tenant_slug: str = "default",
user_email: str = "admin@example.local", user_email: str = "admin@example.local",
user_password: str = "dev-admin",
) -> BootstrapResult: ) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none() tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None: if tenant is None:
@@ -76,9 +78,23 @@ def bootstrap_dev_data(
user = session.query(User).filter(User.tenant_id == tenant.id, User.email == user_email).one_or_none() user = session.query(User).filter(User.tenant_id == tenant.id, User.email == user_email).one_or_none()
if user is None: if user is None:
user = User(tenant_id=tenant.id, email=user_email, display_name="Development Admin", is_tenant_admin=True) user = User(tenant_id=tenant.id, email=user_email, display_name="Development Admin", is_tenant_admin=True, password_hash=hash_password(user_password))
session.add(user) session.add(user)
session.flush() session.flush()
elif not user.password_hash:
user.password_hash = hash_password(user_password)
session.add(user)
# Development owner role assignment for RBAC/session login.
owner_role = session.query(Role).filter(Role.tenant_id == tenant.id, Role.slug == "owner").one_or_none()
if owner_role is not None:
existing_assignment = session.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant.id,
UserRoleAssignment.user_id == user.id,
UserRoleAssignment.role_id == owner_role.id,
).one_or_none()
if existing_assignment is None:
session.add(UserRoleAssignment(tenant_id=tenant.id, user_id=user.id, role_id=owner_role.id))
created_api_key = None created_api_key = None
if api_key_secret: if api_key_secret:

View File

@@ -117,9 +117,13 @@ class User(Base, TimestampMixin):
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
auth_provider: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(500))
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tenant: Mapped[Tenant] = relationship(back_populates="users") tenant: Mapped[Tenant] = relationship(back_populates="users")
api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan") api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
class Group(Base, TimestampMixin): class Group(Base, TimestampMixin):
@@ -143,6 +147,38 @@ class Role(Base, TimestampMixin):
permissions: Mapped[list[str]] = mapped_column(JSON, default=list) permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
class UserGroupMembership(Base, TimestampMixin):
__tablename__ = "user_group_memberships"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
class UserRoleAssignment(Base, TimestampMixin):
__tablename__ = "user_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
class GroupRoleAssignment(Base, TimestampMixin):
__tablename__ = "group_role_assignments"
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
class ApiKey(Base, TimestampMixin): class ApiKey(Base, TimestampMixin):
__tablename__ = "api_keys" __tablename__ = "api_keys"
@@ -160,6 +196,24 @@ class ApiKey(Base, TimestampMixin):
user: Mapped[User] = relationship(back_populates="api_keys") user: Mapped[User] = relationship(back_populates="api_keys")
class AuthSession(Base, TimestampMixin):
__tablename__ = "auth_sessions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
user_agent: Mapped[str | None] = mapped_column(String(500))
ip_address: Mapped[str | None] = mapped_column(String(100))
user: Mapped[User] = relationship(back_populates="auth_sessions")
class Campaign(Base, TimestampMixin): class Campaign(Base, TimestampMixin):
__tablename__ = "campaigns" __tablename__ = "campaigns"
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),) __table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import fnmatch import fnmatch
import re
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@@ -8,6 +9,7 @@ from typing import Any, Iterable
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.mailer.campaign.entries import load_campaign_entries from app.mailer.campaign.entries import load_campaign_entries
from app.mailer.campaign.field_values import effective_entry_field_values
from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig
@@ -126,11 +128,39 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
return (campaign_path.parent / path).resolve() return (campaign_path.parent / path).resolve()
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def _render_template(template: str, values: dict[str, Any]) -> str: def _render_template(template: str, values: dict[str, Any]) -> str:
rendered = template def replace(match: re.Match[str]) -> str:
for key, value in values.items(): key = _normalize_template_key(match.group(1))
rendered = rendered.replace("${" + key + "}", "" if value is None else str(value)) if key in values:
return rendered value = values[key]
return "" if value is None else str(value)
return match.group(0)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _recipient_values(entry: EntryConfig) -> dict[str, str]: def _recipient_values(entry: EntryConfig) -> dict[str, str]:
@@ -153,7 +183,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
values: dict[str, Any] = {} values: dict[str, Any] = {}
for key, value in config.global_values.items(): for key, value in config.global_values.items():
values[f"global::{key}"] = value values[f"global::{key}"] = value
for key, value in entry.fields.items(): for key, value in effective_entry_field_values(config, entry).items():
values[f"local::{key}"] = value values[f"local::{key}"] = value
if entry.id: if entry.id:
values["local::id"] = entry.id values["local::id"] = entry.id

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from .models import CampaignConfig, EntryConfig, FieldDefinition
def field_definitions_by_name(config: CampaignConfig) -> dict[str, FieldDefinition]:
"""Return campaign field definitions keyed by field id/name."""
return {field.name: field for field in config.fields}
def field_can_override(config: CampaignConfig, field_name: str) -> bool:
"""Return whether a recipient/entry value may override the global value.
Unknown fields remain overridable for backwards compatibility with older
campaigns and ad-hoc external mappings. Semantic validation reports unknown
field usage separately when a field list is configured.
"""
field = field_definitions_by_name(config).get(field_name)
if field is None:
return True
return field.can_override
def ignored_entry_field_overrides(config: CampaignConfig, entry: EntryConfig) -> list[str]:
"""Return recipient field keys that are ignored by the override policy."""
return sorted(name for name in entry.fields if not field_can_override(config, name))
def effective_entry_field_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
"""Return the local/effective field value map for one message entry.
Global values act as defaults for local template placeholders. Recipient
values replace those defaults only when the corresponding field allows
overrides. Fields that are unknown to the campaign definition keep the old
permissive behavior and remain usable as local values.
"""
values: dict[str, Any] = dict(config.global_values)
for key, value in entry.fields.items():
if field_can_override(config, key) and entry_field_has_override_value(value):
values[key] = value
return values
def entry_field_has_override_value(value: Any) -> bool:
"""Return whether an entry field should override a global default.
Empty recipient values are treated as "not set" so global_values remain the
effective local defaults. Numeric zero and boolean false are valid explicit
overrides.
"""
if value is None:
return False
if isinstance(value, str):
return value.strip() != ""
return True

View File

@@ -91,6 +91,7 @@ class FieldDefinition(StrictModel):
type: FieldType = FieldType.STRING type: FieldType = FieldType.STRING
label: str | None = None label: str | None = None
required: bool = False required: bool = False
can_override: bool = True
class SmtpConfig(StrictModel): class SmtpConfig(StrictModel):

View File

@@ -7,7 +7,8 @@ from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from .models import CampaignConfig, SourceType from .field_values import ignored_entry_field_overrides
from .models import CampaignConfig, EntryConfig, SourceType
class Severity(StrEnum): class Severity(StrEnum):
@@ -61,6 +62,12 @@ def _resolve(campaign_file: Path, raw_path: str) -> Path:
return (campaign_file.parent / path).resolve() return (campaign_file.parent / path).resolve()
def _mapping_target_field_name(target: str) -> str | None:
if target.startswith("fields."):
return target.split(".", 1)[1]
return None
def _mapping_target_known(target: str, field_names: set[str]) -> bool: def _mapping_target_known(target: str, field_names: set[str]) -> bool:
direct_targets = { direct_targets = {
"id", "id",
@@ -129,6 +136,18 @@ def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, s
return paths return paths
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
return [
_issue(
Severity.WARNING,
"field_override_not_allowed",
f"recipient value for field {field_name!r} will be ignored because the field does not allow overrides",
f"{path_prefix}/fields/{field_name}",
)
for field_name in ignored_entry_field_overrides(config, entry)
]
def validate_campaign_config( def validate_campaign_config(
config: CampaignConfig, config: CampaignConfig,
*, *,
@@ -139,7 +158,8 @@ def validate_campaign_config(
issues: list[SemanticIssue] = [] issues: list[SemanticIssue] = []
field_names = config.field_names field_names = config.field_names
declared_names = {field.name for field in config.fields} field_definitions = {field.name: field for field in config.fields}
declared_names = set(field_definitions)
for key in config.global_values: for key in config.global_values:
if declared_names and key not in declared_names: if declared_names and key not in declared_names:
@@ -187,10 +207,13 @@ def validate_campaign_config(
)) ))
if config.entries.is_inline: if config.entries.is_inline:
entries_count = len(config.entries.inline or []) inline_entries = config.entries.inline or []
entries_count = len(inline_entries)
entries_mode = "inline" entries_mode = "inline"
if entries_count == 0: if entries_count == 0:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline")) issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
else: else:
entries_count = None entries_count = None
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}" entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
@@ -205,6 +228,16 @@ def validate_campaign_config(
f"mapping target {target!r} is not recognized by the current campaign model", f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}", f"/entries/mapping/{target}",
)) ))
field_name = _mapping_target_field_name(target)
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
issues.append(_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source: if check_files and config.entries.source:
source_path = _resolve(campaign_path, config.entries.source.path) source_path = _resolve(campaign_path, config.entries.source.path)
if not source_path.exists(): if not source_path.exists():

View File

@@ -18,6 +18,7 @@ class FieldType(StrEnum):
class FieldDescription: class FieldDescription:
name: str name: str
type: FieldType = FieldType.STRING type: FieldType = FieldType.STRING
can_override: bool = True
@dataclass(slots=True) @dataclass(slots=True)

View File

@@ -17,6 +17,7 @@ from app.mailer.attachments.resolver import (
resolve_entry_attachments, resolve_entry_attachments,
) )
from app.mailer.campaign.entries import load_campaign_entries from app.mailer.campaign.entries import load_campaign_entries
from app.mailer.campaign.field_values import effective_entry_field_values, ignored_entry_field_overrides
from app.mailer.campaign.models import ( from app.mailer.campaign.models import (
Behavior, Behavior,
BuildStatus, BuildStatus,
@@ -38,7 +39,26 @@ from .models import (
MessageValidationStatus, MessageValidationStatus,
) )
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}") _DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
@dataclass(slots=True) @dataclass(slots=True)
@@ -70,20 +90,25 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str: def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str: def replace(match: re.Match[str]) -> str:
key = match.group(1) key = _normalize_template_key(match.group(1))
if key in values: if key in values:
value = values[key] value = values[key]
return "" if value is None else str(value) return "" if value is None else str(value)
return match.group(0) if keep_missing else "" return match.group(0) if keep_missing else ""
rendered = _FIELD_PATTERN.sub(replace, template) rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}") return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _find_unresolved_placeholders(text: str | None) -> set[str]: def _find_unresolved_placeholders(text: str | None) -> set[str]:
if not text: if not text:
return set() return set()
return set(_FIELD_PATTERN.findall(text)) return {
_normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
def _recipient_values(entry: EntryConfig) -> dict[str, str]: def _recipient_values(entry: EntryConfig) -> dict[str, str]:
@@ -106,7 +131,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
values: dict[str, Any] = {} values: dict[str, Any] = {}
for key, value in config.global_values.items(): for key, value in config.global_values.items():
values[f"global::{key}"] = value values[f"global::{key}"] = value
for key, value in entry.fields.items(): for key, value in effective_entry_field_values(config, entry).items():
values[f"local::{key}"] = value values[f"local::{key}"] = value
if entry.id: if entry.id:
values["local::id"] = entry.id values["local::id"] = entry.id
@@ -390,6 +415,20 @@ def build_entry_message(
issues = _message_issues_from_attachment_resolution(resolution) issues = _message_issues_from_attachment_resolution(resolution)
validation_status = _validation_status_from_attachment_status(resolution.status) validation_status = _validation_status_from_attachment_status(resolution.status)
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
if ignored_field_overrides:
issues.append(
MessageIssue(
severity="warning",
code="field_override_not_allowed",
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides),
behavior="warn",
source="fields",
)
)
if validation_status == MessageValidationStatus.READY:
validation_status = MessageValidationStatus.WARNING
if not entry.active: if not entry.active:
draft = MessageDraft( draft = MessageDraft(
entry_index=entry_index, entry_index=entry_index,

View File

@@ -70,6 +70,11 @@
"required": { "required": {
"type": "boolean", "type": "boolean",
"default": false "default": false
},
"can_override": {
"type": "boolean",
"default": true,
"description": "Whether recipient/entry field values may override the global value for this field."
} }
}, },
"additionalProperties": false "additionalProperties": false

View File

@@ -26,6 +26,29 @@ class ImapAppendError(RuntimeError):
self.temporary = temporary self.temporary = temporary
@dataclass(frozen=True, slots=True)
class ImapLoginTestResult:
host: str
port: int
security: str
authenticated: bool
@dataclass(frozen=True, slots=True)
class ImapMailboxInfo:
name: str
flags: list[str]
@dataclass(frozen=True, slots=True)
class ImapFolderListResult:
host: str
port: int
security: str
folders: list[ImapMailboxInfo]
detected_sent_folder: str | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ImapAppendResult: class ImapAppendResult:
host: str host: str
@@ -83,43 +106,57 @@ def _decode_item(item: bytes | str | None) -> str:
return item return item
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None: def _unquote_imap_token(value: str) -> str:
"""Best-effort parser for IMAP LIST response lines. value = value.strip()
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
value = value[1:-1]
value = value.replace('\\"', '"').replace('\\\\', '\\')
return value
Example lines:
(\\HasNoChildren \\Sent) "/" "Sent" def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
(\\HasNoChildren) "/" "Sent Items" r"""Best-effort parser for IMAP LIST response lines.
RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox
name. Some servers quote both delimiter and mailbox::
(\HasNoChildren \Sent) "/" "Sent"
Others quote only the delimiter and leave the mailbox as an atom::
(\HasNoChildren \Sent) "/" Sent
The parser must therefore parse the delimiter token separately instead of
blindly taking the last quoted value.
""" """
line = _decode_item(list_response_line).strip() line = _decode_item(list_response_line).strip()
flags_match = re.match(r"^\((?P<flags>[^)]*)\)\s+", line) match = re.match(
flags = set() r'^\((?P<flags>[^)]*)\)\s+'
if flags_match: r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+'
flags = {part.lower() for part in flags_match.group("flags").split()} r'(?P<mailbox>.+?)\s*$',
line,
re.IGNORECASE,
)
if match:
flags = {part.lower() for part in match.group("flags").split()}
mailbox = _unquote_imap_token(match.group("mailbox"))
if mailbox:
return mailbox, flags
return None
quoted = re.findall(r'"((?:[^"\\]|\\.)*)"', line) # Fallback for non-standard server lines: prefer the final token.
if quoted: parts = line.split(maxsplit=2)
# Usually: delimiter, mailbox. Take the last quoted token. if len(parts) >= 3:
return quoted[-1].replace(r'\"', '"'), flags flags_text = parts[0].strip("()")
flags = {part.lower() for part in flags_text.split()}
# Fallback for unquoted final atom. mailbox = _unquote_imap_token(parts[2])
parts = line.split() if mailbox:
if parts: return mailbox, flags
return parts[-1], flags
return None return None
def discover_sent_folder(client: imaplib.IMAP4) -> str | None: def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
typ, data = client.list()
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
for name, flags in parsed: for name, flags in parsed:
if "\\sent" in flags or "\\sentmail" in flags: if "\\sent" in flags or "\\sentmail" in flags:
return name return name
@@ -140,6 +177,20 @@ def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
return None return None
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
typ, data = client.list()
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
return _detect_sent_folder(parsed)
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str: def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
if requested_folder and requested_folder != "auto": if requested_folder and requested_folder != "auto":
return requested_folder return requested_folder
@@ -151,6 +202,63 @@ def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None,
raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder") raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder")
def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
"""Open an IMAP connection and authenticate if credentials are configured.
This is a side-effect-free connection test for the WebUI. It does not select
a mailbox and does not append any message.
"""
host, port = _require_imap_config(imap_config)
client = _open_imap(imap_config)
try:
return ImapLoginTestResult(
host=host,
port=port,
security=imap_config.security.value,
authenticated=bool(imap_config.username and imap_config.password),
)
finally:
try:
client.logout()
except Exception:
pass
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config)
client = _open_imap(imap_config)
try:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
)
finally:
try:
client.logout()
except Exception:
pass
def append_message_to_sent( def append_message_to_sent(
message_bytes: bytes, message_bytes: bytes,
*, *,

View File

@@ -18,6 +18,14 @@ class SmtpSendError(RuntimeError):
"""Raised when an SMTP send attempt fails.""" """Raised when an SMTP send attempt fails."""
@dataclass(frozen=True, slots=True)
class SmtpLoginTestResult:
host: str
port: int
security: str
authenticated: bool
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SmtpSendResult: class SmtpSendResult:
host: str host: str
@@ -80,6 +88,34 @@ def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[in
return normalized return normalized
def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
"""Open an SMTP connection and authenticate if credentials are configured.
This is intentionally side-effect free: it does not send a message and it
never receives envelope or recipient data. It is used by the WebUI to check
whether the configured transport can be reached before a campaign is built
or queued.
"""
host, port = _require_smtp_config(smtp_config)
smtp = _open_smtp(smtp_config)
try:
return SmtpLoginTestResult(
host=host,
port=port,
security=smtp_config.security.value,
authenticated=bool(smtp_config.username and smtp_config.password),
)
finally:
try:
smtp.quit()
except Exception:
try:
smtp.close()
except Exception:
pass
def prepare_test_message( def prepare_test_message(
message: EmailMessage, message: EmailMessage,
*, *,

View File

@@ -14,7 +14,7 @@ async def lifespan(app: FastAPI):
if settings.app_env == "dev" and settings.dev_bootstrap_enabled: if settings.app_env == "dev" and settings.dev_bootstrap_enabled:
create_all_tables() create_all_tables()
with SessionLocal() as session: with SessionLocal() as session:
bootstrap_dev_data(session, api_key_secret=settings.dev_bootstrap_api_key) bootstrap_dev_data(session, api_key_secret=settings.dev_bootstrap_api_key, user_password=settings.dev_bootstrap_password)
yield yield
@@ -38,7 +38,7 @@ def health():
return { return {
"status": "ok", "status": "ok",
"env": settings.app_env, "env": settings.app_env,
"api": {"version": "v1", "auth": "api-key"}, "api": {"version": "v1", "auth": "api-key-or-session"},
"storage": { "storage": {
"endpoint": settings.s3_endpoint_url, "endpoint": settings.s3_endpoint_url,
"bucket": settings.s3_bucket, "bucket": settings.s3_bucket,

View File

@@ -4,11 +4,12 @@ import hashlib
import hmac import hmac
import secrets import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import ApiKey, User from app.db.models import ApiKey, User
from app.security.time import ensure_aware_utc, utc_now
API_KEY_PREFIX_LENGTH = 12 API_KEY_PREFIX_LENGTH = 12
API_KEY_RANDOM_BYTES = 32 API_KEY_RANDOM_BYTES = 32
@@ -64,9 +65,10 @@ def create_api_key(
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
prefix = api_key_prefix(secret) prefix = api_key_prefix(secret)
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all() candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
now = datetime.now(timezone.utc) now = utc_now()
for candidate in candidates: for candidate in candidates:
if candidate.expires_at and candidate.expires_at < now: expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now:
continue continue
if verify_api_key(secret, candidate.key_hash): if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now candidate.last_used_at = now

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
_ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return "$".join([
_ALGORITHM,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
])
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
if algorithm != _ALGORITHM:
return False
iterations = int(iterations_text)
salt = base64.b64decode(salt_b64.encode("ascii"))
expected = base64.b64decode(digest_b64.encode("ascii"))
except Exception:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy.orm import Session
from app.db.models import AuthSession, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
from app.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32
DEFAULT_SESSION_HOURS = 12
@dataclass(slots=True)
class CreatedSession:
model: AuthSession
token: str
def generate_session_token() -> str:
return "ms_" + secrets.token_urlsafe(SESSION_RANDOM_BYTES)
def hash_session_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def verify_session_token(token: str, expected_hash: str) -> bool:
return hmac.compare_digest(hash_session_token(token), expected_hash)
def create_auth_session(
session: Session,
*,
user: User,
hours: int = DEFAULT_SESSION_HOURS,
user_agent: str | None = None,
ip_address: str | None = None,
) -> CreatedSession:
token = generate_session_token()
now = utc_now()
model = AuthSession(
tenant_id=user.tenant_id,
user_id=user.id,
token_hash=hash_session_token(token),
expires_at=now + timedelta(hours=hours),
last_seen_at=now,
user_agent=user_agent,
ip_address=ip_address,
)
user.last_login_at = now
session.add(model)
session.add(user)
session.flush()
return CreatedSession(model=model, token=token)
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
token_hash = hash_session_token(token)
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
if not model:
return None
now = utc_now()
expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now:
return None
model.last_seen_at = now
session.add(model)
return model
def revoke_auth_session(session: Session, token: str) -> bool:
model = authenticate_session_token(session, token)
if not model:
return False
model.revoked_at = utc_now()
session.add(model)
return True
def collect_user_roles(session: Session, user: User) -> list[Role]:
roles_by_id: dict[str, Role] = {}
direct = (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(UserRoleAssignment.user_id == user.id, UserRoleAssignment.tenant_id == user.tenant_id)
.all()
)
for role in direct:
roles_by_id[role.id] = role
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.filter(UserGroupMembership.user_id == user.id, UserGroupMembership.tenant_id == user.tenant_id)
.all()
)
for role in group_roles:
roles_by_id[role.id] = role
return list(roles_by_id.values())
def collect_user_groups(session: Session, user: User) -> list[Group]:
return (
session.query(Group)
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
.filter(UserGroupMembership.user_id == user.id, UserGroupMembership.tenant_id == user.tenant_id)
.all()
)
def collect_user_scopes(session: Session, user: User) -> list[str]:
if user.is_tenant_admin:
return ["*"]
scopes: set[str] = set()
for role in collect_user_roles(session, user):
scopes.update(role.permissions or [])
return sorted(scopes)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: datetime | None) -> datetime | None:
"""Return a timezone-aware UTC datetime.
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
columns are declared as DateTime(timezone=True). Treat naive values as UTC
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
"""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)

View File

@@ -25,6 +25,7 @@ class Settings(BaseSettings):
# Development bootstrap only. Do not use this in production. # Development bootstrap only. Do not use this in production.
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY") dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_bootstrap_enabled: bool = Field(default=True, alias="DEV_BOOTSTRAP_ENABLED") dev_bootstrap_enabled: bool = Field(default=True, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")
# Comma-separated list. Use * only for local development. # Comma-separated list. Use * only for local development.
cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS") cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS")

Binary file not shown.