added backends, improved templating, rbac
This commit is contained in:
21
.gitignore
vendored
21
.gitignore
vendored
@@ -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
|
||||||
@@ -175,4 +175,9 @@ cython_debug/
|
|||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
.vscode/**
|
.vscode/**
|
||||||
|
|
||||||
|
runtime
|
||||||
|
runtime/
|
||||||
|
|
||||||
|
server/*.db
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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.
|
||||||
|
|||||||
Binary file not shown.
130
server/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py
Normal file
130
server/alembic/versions/2c3d4e5f6a7b_auth_sessions_and_rbac.py
Normal 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")
|
||||||
@@ -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
128
server/app/api/v1/auth.py
Normal 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
116
server/app/api/v1/mail.py
Normal 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__},
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
user = session.get(User, api_key.user_id)
|
if api_key:
|
||||||
if not user or not user.is_active:
|
user = session.get(User, api_key.user_id)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
|
if not user or not user.is_active:
|
||||||
session.commit()
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
|
||||||
return ApiPrincipal(api_key=api_key, user=user, tenant_id=api_key.tenant_id)
|
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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
62
server/app/mailer/campaign/field_values.py
Normal file
62
server/app/mailer/campaign/field_values.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
server/app/security/passwords.py
Normal file
37
server/app/security/passwords.py
Normal 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)
|
||||||
123
server/app/security/sessions.py
Normal file
123
server/app/security/sessions.py
Normal 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)
|
||||||
21
server/app/security/time.py
Normal file
21
server/app/security/time.py
Normal 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)
|
||||||
@@ -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.
Reference in New Issue
Block a user