diff --git a/.gitignore b/.gitignore index 9d66d51..77a7a53 100644 --- a/.gitignore +++ b/.gitignore @@ -129,13 +129,13 @@ celerybeat.pid *.sage.py # Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +.env* +.venv* +env*/ +venv*/ +ENV*/ +env*.bak/ +venv*.bak/ # Spyder project settings .spyderproject @@ -175,4 +175,9 @@ cython_debug/ .pypirc .vscode -.vscode/** \ No newline at end of file +.vscode/** + +runtime +runtime/ + +server/*.db diff --git a/README.md b/README.md index 32f4e42..5e38d4e 100644 --- a/README.md +++ b/README.md @@ -922,3 +922,28 @@ curl -X POST 'http://127.0.0.1:8000/api/v1/campaigns//versions/`. 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. diff --git a/multimailer_python_java_port_with_editable_campaign_versions.zip b/multimailer_python_java_port_with_editable_campaign_versions.zip deleted file mode 100644 index ca70024..0000000 Binary files a/multimailer_python_java_port_with_editable_campaign_versions.zip and /dev/null differ diff --git a/server/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py b/server/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py new file mode 100644 index 0000000..d3756a9 --- /dev/null +++ b/server/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py @@ -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") diff --git a/server/app/api/v1/__init__.py b/server/app/api/v1/__init__.py index cfd035d..b0cec4e 100644 --- a/server/app/api/v1/__init__.py +++ b/server/app/api/v1/__init__.py @@ -1,12 +1,16 @@ from fastapi import APIRouter from .admin import router as admin_router +from .auth import router as auth_router from .campaigns import router as campaigns_router from .audit import router as audit_router from .system import router as system_router +from .mail import router as mail_router router = APIRouter(prefix="/api/v1") +router.include_router(auth_router) router.include_router(campaigns_router) router.include_router(admin_router) router.include_router(audit_router) router.include_router(system_router) +router.include_router(mail_router) diff --git a/server/app/api/v1/auth.py b/server/app/api/v1/auth.py new file mode 100644 index 0000000..854ef22 --- /dev/null +++ b/server/app/api/v1/auth.py @@ -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} diff --git a/server/app/api/v1/mail.py b/server/app/api/v1/mail.py new file mode 100644 index 0000000..3c48ecf --- /dev/null +++ b/server/app/api/v1/mail.py @@ -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__}, + ) diff --git a/server/app/api/v1/schemas.py b/server/app/api/v1/schemas.py index 603e5fc..7d5aad6 100644 --- a/server/app/api/v1/schemas.py +++ b/server/app/api/v1/schemas.py @@ -1,10 +1,12 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field +from app.mailer.campaign.models import ImapConfig, SmtpConfig + class CampaignCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -126,6 +128,43 @@ class BuildCampaignRequest(BaseModel): 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): model_config = ConfigDict(extra="forbid") @@ -200,3 +239,70 @@ class AuditLogItemResponse(BaseModel): class AuditLogListResponse(BaseModel): 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) diff --git a/server/app/audit/logging.py b/server/app/audit/logging.py index fe6b698..8f96fbf 100644 --- a/server/app/audit/logging.py +++ b/server/app/audit/logging.py @@ -82,7 +82,7 @@ def audit_from_principal( session, tenant_id=principal.tenant_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, object_type=object_type, object_id=object_id, diff --git a/server/app/auth/dependencies.py b/server/app/auth/dependencies.py index 9a62a0c..25449bf 100644 --- a/server/app/auth/dependencies.py +++ b/server/app/auth/dependencies.py @@ -5,24 +5,27 @@ from dataclasses import dataclass from fastapi import Depends, Header, HTTPException, status 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.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) class ApiPrincipal: - api_key: ApiKey user: User 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: - return x_api_key.strip() + return x_api_key.strip(), "api_key" if authorization and authorization.lower().startswith("bearer "): - return authorization[7:].strip() - return None + return authorization[7:].strip(), "bearer" + return None, "none" def get_api_principal( @@ -30,22 +33,41 @@ def get_api_principal( authorization: str | None = Header(default=None), x_api_key: str | None = Header(default=None, alias="X-API-Key"), ) -> ApiPrincipal: - secret = _extract_api_key(authorization, x_api_key) - if not secret: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key") - api_key = authenticate_api_key(session, secret) - if not api_key: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") - user = session.get(User, api_key.user_id) - if not user or not user.is_active: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user") - session.commit() - return ApiPrincipal(api_key=api_key, user=user, tenant_id=api_key.tenant_id) + token, source = _extract_token(authorization, x_api_key) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") + + # API keys remain supported for CLI/automation. Browser login uses session tokens. + api_key = authenticate_api_key(session, token) + if api_key: + user = session.get(User, api_key.user_id) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user") + session.commit() + 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 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}") return principal diff --git a/server/app/db/bootstrap.py b/server/app/db/bootstrap.py index 7bd844c..a1a343a 100644 --- a/server/app/db/bootstrap.py +++ b/server/app/db/bootstrap.py @@ -5,9 +5,10 @@ from dataclasses import dataclass from sqlalchemy.orm import Session 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.security.api_keys import CreatedApiKey, create_api_key +from app.security.passwords import hash_password DEFAULT_SCOPES = [ "campaign:read", @@ -62,6 +63,7 @@ def bootstrap_dev_data( api_key_secret: str | None = None, tenant_slug: str = "default", user_email: str = "admin@example.local", + user_password: str = "dev-admin", ) -> BootstrapResult: tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_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() 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.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 if api_key_secret: diff --git a/server/app/db/models.py b/server/app/db/models.py index f0dfa47..1d41040 100644 --- a/server/app/db/models.py +++ b/server/app/db/models.py @@ -117,9 +117,13 @@ class User(Base, TimestampMixin): display_name: Mapped[str | None] = mapped_column(String(255)) is_active: Mapped[bool] = mapped_column(Boolean, default=True, 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") 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): @@ -143,6 +147,38 @@ class Role(Base, TimestampMixin): 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): __tablename__ = "api_keys" @@ -160,6 +196,24 @@ class ApiKey(Base, TimestampMixin): 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): __tablename__ = "campaigns" __table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),) diff --git a/server/app/mailer/attachments/resolver.py b/server/app/mailer/attachments/resolver.py index 9d10f98..bf9960f 100644 --- a/server/app/mailer/attachments/resolver.py +++ b/server/app/mailer/attachments/resolver.py @@ -1,6 +1,7 @@ from __future__ import annotations import fnmatch +import re from enum import StrEnum from pathlib import Path from typing import Any, Iterable @@ -8,6 +9,7 @@ from typing import Any, Iterable from pydantic import BaseModel, ConfigDict, Field 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 @@ -126,11 +128,39 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path: return (campaign_path.parent / path).resolve() +_DOLLAR_FIELD_PATTERN = re.compile(r"(? 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: - rendered = template - for key, value in values.items(): - rendered = rendered.replace("${" + key + "}", "" if value is None else str(value)) - return rendered + def replace(match: re.Match[str]) -> str: + key = _normalize_template_key(match.group(1)) + if key in values: + 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]: @@ -153,7 +183,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An values: dict[str, Any] = {} for key, value in config.global_values.items(): 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 if entry.id: values["local::id"] = entry.id diff --git a/server/app/mailer/campaign/field_values.py b/server/app/mailer/campaign/field_values.py new file mode 100644 index 0000000..ddd2011 --- /dev/null +++ b/server/app/mailer/campaign/field_values.py @@ -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 diff --git a/server/app/mailer/campaign/models.py b/server/app/mailer/campaign/models.py index 93e24f1..1ca0ade 100644 --- a/server/app/mailer/campaign/models.py +++ b/server/app/mailer/campaign/models.py @@ -91,6 +91,7 @@ class FieldDefinition(StrictModel): type: FieldType = FieldType.STRING label: str | None = None required: bool = False + can_override: bool = True class SmtpConfig(StrictModel): diff --git a/server/app/mailer/campaign/validation.py b/server/app/mailer/campaign/validation.py index 12135e2..4b5eaa8 100644 --- a/server/app/mailer/campaign/validation.py +++ b/server/app/mailer/campaign/validation.py @@ -7,7 +7,8 @@ from typing import Iterable 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): @@ -61,6 +62,12 @@ def _resolve(campaign_file: Path, raw_path: str) -> Path: 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: direct_targets = { "id", @@ -129,6 +136,18 @@ def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, s 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( config: CampaignConfig, *, @@ -139,7 +158,8 @@ def validate_campaign_config( issues: list[SemanticIssue] = [] 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: if declared_names and key not in declared_names: @@ -187,10 +207,13 @@ def validate_campaign_config( )) 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" if entries_count == 0: 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: entries_count = None 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"/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: source_path = _resolve(campaign_path, config.entries.source.path) if not source_path.exists(): diff --git a/server/app/mailer/domain/fields.py b/server/app/mailer/domain/fields.py index fb0f112..5ed29fb 100644 --- a/server/app/mailer/domain/fields.py +++ b/server/app/mailer/domain/fields.py @@ -18,6 +18,7 @@ class FieldType(StrEnum): class FieldDescription: name: str type: FieldType = FieldType.STRING + can_override: bool = True @dataclass(slots=True) diff --git a/server/app/mailer/messages/builder.py b/server/app/mailer/messages/builder.py index f2b10ec..d013bb8 100644 --- a/server/app/mailer/messages/builder.py +++ b/server/app/mailer/messages/builder.py @@ -17,6 +17,7 @@ from app.mailer.attachments.resolver import ( resolve_entry_attachments, ) 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 ( Behavior, BuildStatus, @@ -38,7 +39,26 @@ from .models import ( MessageValidationStatus, ) -_FIELD_PATTERN = re.compile(r"(? 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) @@ -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 replace(match: re.Match[str]) -> str: - key = match.group(1) + key = _normalize_template_key(match.group(1)) if key in values: value = values[key] return "" if value is None else str(value) 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"\}", "}") def _find_unresolved_placeholders(text: str | None) -> set[str]: if not text: 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]: @@ -106,7 +131,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An values: dict[str, Any] = {} for key, value in config.global_values.items(): 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 if entry.id: values["local::id"] = entry.id @@ -390,6 +415,20 @@ def build_entry_message( issues = _message_issues_from_attachment_resolution(resolution) 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: draft = MessageDraft( entry_index=entry_index, diff --git a/server/app/mailer/schema/campaign.schema.json b/server/app/mailer/schema/campaign.schema.json index b82fa19..e25510c 100644 --- a/server/app/mailer/schema/campaign.schema.json +++ b/server/app/mailer/schema/campaign.schema.json @@ -70,6 +70,11 @@ "required": { "type": "boolean", "default": false + }, + "can_override": { + "type": "boolean", + "default": true, + "description": "Whether recipient/entry field values may override the global value for this field." } }, "additionalProperties": false diff --git a/server/app/mailer/sending/imap.py b/server/app/mailer/sending/imap.py index 6de25f2..a5aa251 100644 --- a/server/app/mailer/sending/imap.py +++ b/server/app/mailer/sending/imap.py @@ -26,6 +26,29 @@ class ImapAppendError(RuntimeError): 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) class ImapAppendResult: host: str @@ -83,43 +106,57 @@ def _decode_item(item: bytes | str | None) -> str: return item -def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None: - """Best-effort parser for IMAP LIST response lines. +def _unquote_imap_token(value: str) -> str: + 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" - (\\HasNoChildren) "/" "Sent Items" + +def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None: + 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() - flags_match = re.match(r"^\((?P[^)]*)\)\s+", line) - flags = set() - if flags_match: - flags = {part.lower() for part in flags_match.group("flags").split()} + match = re.match( + r'^\((?P[^)]*)\)\s+' + r'(?P"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+' + r'(?P.+?)\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) - if quoted: - # Usually: delimiter, mailbox. Take the last quoted token. - return quoted[-1].replace(r'\"', '"'), flags - - # Fallback for unquoted final atom. - parts = line.split() - if parts: - return parts[-1], flags + # Fallback for non-standard server lines: prefer the final token. + parts = line.split(maxsplit=2) + if len(parts) >= 3: + flags_text = parts[0].strip("()") + flags = {part.lower() for part in flags_text.split()} + mailbox = _unquote_imap_token(parts[2]) + if mailbox: + return mailbox, flags 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) - +def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None: for name, flags in parsed: if "\\sent" in flags or "\\sentmail" in flags: return name @@ -140,6 +177,20 @@ def discover_sent_folder(client: imaplib.IMAP4) -> str | 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: if requested_folder and requested_folder != "auto": 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") +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( message_bytes: bytes, *, diff --git a/server/app/mailer/sending/smtp.py b/server/app/mailer/sending/smtp.py index 52f38e6..1ea498e 100644 --- a/server/app/mailer/sending/smtp.py +++ b/server/app/mailer/sending/smtp.py @@ -18,6 +18,14 @@ class SmtpSendError(RuntimeError): """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) class SmtpSendResult: host: str @@ -80,6 +88,34 @@ def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[in 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( message: EmailMessage, *, diff --git a/server/app/main.py b/server/app/main.py index 008bf4e..9365731 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -14,7 +14,7 @@ async def lifespan(app: FastAPI): if settings.app_env == "dev" and settings.dev_bootstrap_enabled: create_all_tables() 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 @@ -38,7 +38,7 @@ def health(): return { "status": "ok", "env": settings.app_env, - "api": {"version": "v1", "auth": "api-key"}, + "api": {"version": "v1", "auth": "api-key-or-session"}, "storage": { "endpoint": settings.s3_endpoint_url, "bucket": settings.s3_bucket, diff --git a/server/app/security/api_keys.py b/server/app/security/api_keys.py index b1723c5..cf95240 100644 --- a/server/app/security/api_keys.py +++ b/server/app/security/api_keys.py @@ -4,11 +4,12 @@ import hashlib import hmac import secrets from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime from sqlalchemy.orm import Session from app.db.models import ApiKey, User +from app.security.time import ensure_aware_utc, utc_now API_KEY_PREFIX_LENGTH = 12 API_KEY_RANDOM_BYTES = 32 @@ -64,9 +65,10 @@ def create_api_key( def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: prefix = api_key_prefix(secret) 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: - 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 if verify_api_key(secret, candidate.key_hash): candidate.last_used_at = now diff --git a/server/app/security/passwords.py b/server/app/security/passwords.py new file mode 100644 index 0000000..da2ca73 --- /dev/null +++ b/server/app/security/passwords.py @@ -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) diff --git a/server/app/security/sessions.py b/server/app/security/sessions.py new file mode 100644 index 0000000..173f48e --- /dev/null +++ b/server/app/security/sessions.py @@ -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) diff --git a/server/app/security/time.py b/server/app/security/time.py new file mode 100644 index 0000000..163654b --- /dev/null +++ b/server/app/security/time.py @@ -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) diff --git a/server/app/settings.py b/server/app/settings.py index b754758..e0ef349 100644 --- a/server/app/settings.py +++ b/server/app/settings.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): # 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_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. cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS") diff --git a/server/multimailer-dev.db b/server/multimailer-dev.db index 3a8f888..99ae282 100644 Binary files a/server/multimailer-dev.db and b/server/multimailer-dev.db differ