diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..02297a0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/mail +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..ff03848 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/mail + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ac7e1ce --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/mail +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..2a5be6e --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/mail +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..3458add --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/mail +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md index f239d86..0619315 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,15 @@ Frontend package: @govoplan/mail-webui ``` -The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection. +The campaign module consumes `mail.campaign_delivery` for sending, +append-to-Sent behavior, profile selection, and policy checks. Mail does not +import campaign internals; campaign-scoped policy and owner context are resolved +through the core `campaigns.mailPolicyContext` capability when the campaign +module is installed. + +Development mailbox routes are registered by the mail module only when the +core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core +does not contribute these routes directly. Platform RBAC and governance rules are documented in `govoplan-core/docs/`. diff --git a/package.json b/package.json index d022025..a0f9210 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -19,7 +19,7 @@ "LICENSE" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.4", + "@govoplan/core-webui": "^0.1.5", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pyproject.toml b/pyproject.toml index b4207bb..8f9a4d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-mail" -version = "0.1.4" +version = "0.1.5" description = "GovOPlaN mail module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.4", + "govoplan-core>=0.1.5", "pydantic>=2,<3", "redis>=5,<6", "SQLAlchemy>=2,<3", diff --git a/src/govoplan_mail/backend/capabilities.py b/src/govoplan_mail/backend/capabilities.py index 7c701d7..d74f64b 100644 --- a/src/govoplan_mail/backend/capabilities.py +++ b/src/govoplan_mail/backend/capabilities.py @@ -41,6 +41,12 @@ class MailCampaignCapability: append_message_to_sent = staticmethod(append_message_to_sent) send_email_message = staticmethod(send_email_message) + @staticmethod + def mock_mailbox(): + from govoplan_mail.backend.dev import mock_mailbox + + return mock_mailbox + def campaign_capability(context: ModuleContext) -> MailCampaignCapability: configure_runtime(settings=context.settings) diff --git a/src/govoplan_mail/backend/db/models.py b/src/govoplan_mail/backend/db/models.py index b553eb9..4224f1f 100644 --- a/src/govoplan_mail/backend/db/models.py +++ b/src/govoplan_mail/backend/db/models.py @@ -40,3 +40,16 @@ class MailServerProfile(Base, TimestampMixin): tenant: Mapped["Tenant"] = relationship() +class MailProfilePolicy(Base, TimestampMixin): + __tablename__ = "mail_profile_policies" + __table_args__ = ( + UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"), + Index("ix_mail_profile_policies_scope", "scope_type", "scope_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + diff --git a/src/govoplan_mail/backend/dev_router.py b/src/govoplan_mail/backend/dev_router.py index a901244..71f2140 100644 --- a/src/govoplan_mail/backend/dev_router.py +++ b/src/govoplan_mail/backend/dev_router.py @@ -5,7 +5,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, Field -from govoplan_core.auth.dependencies import ApiPrincipal, require_scope +from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_scope from govoplan_mail.backend.dev.mock_mailbox import ( clear_records, get_failures, diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index 2f85c38..26daf7c 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -2,17 +2,23 @@ from __future__ import annotations import copy import fnmatch +import json import re from dataclasses import dataclass, field from typing import Any, Iterable -from sqlalchemy import and_, or_ +from sqlalchemy import and_, or_, text from sqlalchemy.orm import Session -from govoplan_core.admin.governance import get_system_settings -from govoplan_core.core.optional import reraise_unless_missing_package -from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership -from govoplan_mail.backend.db.models import MailServerProfile +from govoplan_core.admin.settings import get_system_settings +from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory +from govoplan_core.core.campaigns import ( + CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT, + CampaignMailPolicyContext, + CampaignMailPolicyContextProvider, +) +from govoplan_core.core.runtime import get_registry +from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile from govoplan_mail.backend.config import ImapConfig, SmtpConfig from govoplan_core.security.secrets import decrypt_secret, encrypt_secret @@ -49,15 +55,6 @@ def default_mail_policy_lower_level_limits() -> dict[str, bool]: return {key: True for key in MAIL_POLICY_LIMIT_KEYS} -def _campaign_model(): - try: - from govoplan_campaign.backend.db.models import Campaign - except ModuleNotFoundError as exc: - reraise_unless_missing_package(exc, "govoplan_campaign") - raise MailProfileError("Campaign module is not installed") from exc - return Campaign - - @dataclass(slots=True) class EffectiveCredentialPolicy: inherit: bool = True @@ -240,6 +237,174 @@ def _policy_from_attr(value: Any) -> dict[str, Any]: return normalize_mail_profile_policy(value if isinstance(value, dict) else {}) +def _json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _access_directory() -> AccessDirectory | None: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY): + return None + capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY) + if not isinstance(capability, AccessDirectory): + raise MailProfileError("Access directory capability is invalid") + return capability + + +def _campaign_policy_provider() -> CampaignMailPolicyContextProvider | None: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT): + return None + capability = registry.require_capability(CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT) + if not isinstance(capability, CampaignMailPolicyContextProvider): + raise MailProfileError("Campaign mail policy context capability is invalid") + return capability + + +def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: str) -> CampaignMailPolicyContext: + provider = _campaign_policy_provider() + if provider is None: + raise MailProfileError("Campaign module is not available") + context = provider.get_campaign_mail_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id) + if context is None: + raise MailProfileError("Campaign not found for mail-server profile policy") + return context + + +def _legacy_tenant_settings(session: Session, tenant_id: str) -> dict[str, Any] | None: + row = session.execute(text("select settings from tenants where id = :tenant_id"), {"tenant_id": tenant_id}).first() + return _json_object(row[0]) if row else None + + +def _legacy_subject_policy(session: Session, *, table_name: str, tenant_id: str, scope_id: str) -> dict[str, Any] | None: + if table_name not in {"users", "groups"}: + raise MailProfileError("Unsupported mail policy subject") + row = session.execute( + text(f"select mail_profile_policy from {table_name} where id = :scope_id and tenant_id = :tenant_id"), + {"scope_id": scope_id, "tenant_id": tenant_id}, + ).first() + return _json_object(row[0]) if row else None + + +def _tenant_exists(session: Session, tenant_id: str) -> bool: + return _legacy_tenant_settings(session, tenant_id) is not None + + +def _user_exists(session: Session, *, tenant_id: str, user_id: str) -> bool: + directory = _access_directory() + if directory is not None: + user = directory.get_user(user_id) + return bool(user and user.tenant_id == tenant_id and user.status == "active") + return _legacy_subject_policy(session, table_name="users", tenant_id=tenant_id, scope_id=user_id) is not None + + +def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool: + directory = _access_directory() + if directory is not None: + group = directory.get_group(group_id) + return bool(group and group.tenant_id == tenant_id and group.status == "active") + return _legacy_subject_policy(session, table_name="groups", tenant_id=tenant_id, scope_id=group_id) is not None + + +def _policy_scope_key(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str | None, str, str | None]: + clean_scope = _normalize_scope_type(scope_type) + if clean_scope == "system": + return None, clean_scope, None + if clean_scope == "tenant": + return tenant_id, clean_scope, scope_id or tenant_id + return tenant_id, clean_scope, scope_id + + +def _mail_policy_row( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, +) -> MailProfilePolicy | None: + row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id) + query = session.query(MailProfilePolicy).filter(MailProfilePolicy.scope_type == row_scope_type) + query = query.filter(MailProfilePolicy.tenant_id.is_(None) if row_tenant_id is None else MailProfilePolicy.tenant_id == row_tenant_id) + query = query.filter(MailProfilePolicy.scope_id.is_(None) if row_scope_id is None else MailProfilePolicy.scope_id == row_scope_id) + return query.one_or_none() + + +def _legacy_mail_profile_policy( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, +) -> dict[str, Any] | None: + clean_scope = _normalize_scope_type(scope_type) + if clean_scope == "system": + return _policy_from_settings(get_system_settings(session).settings or {}) + if clean_scope == "tenant": + if scope_id and scope_id != tenant_id: + return None + settings = _legacy_tenant_settings(session, tenant_id) + return _policy_from_settings(settings or {}) if settings is not None else None + if not scope_id: + return None + if clean_scope == "user": + legacy = _legacy_subject_policy(session, table_name="users", tenant_id=tenant_id, scope_id=scope_id) + return _policy_from_attr(legacy) if legacy is not None else None + if clean_scope == "group": + legacy = _legacy_subject_policy(session, table_name="groups", tenant_id=tenant_id, scope_id=scope_id) + return _policy_from_attr(legacy) if legacy is not None else None + try: + context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) + except MailProfileError: + return None + return _policy_from_attr(context.mail_profile_policy) + + +def _read_mail_profile_policy( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None = None, +) -> dict[str, Any] | None: + clean_scope = _normalize_scope_type(scope_type) + if clean_scope != "campaign": + row = _mail_policy_row(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id) + if row is not None: + return _policy_from_attr(row.policy) + return _legacy_mail_profile_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id) + + +def _write_mail_profile_policy( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, + policy: dict[str, Any], +) -> dict[str, Any]: + row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id) + row = _mail_policy_row(session, tenant_id=tenant_id, scope_type=row_scope_type, scope_id=row_scope_id) + if row is None: + row = MailProfilePolicy( + tenant_id=row_tenant_id, + scope_type=row_scope_type, + scope_id=row_scope_id, + policy=policy, + ) + else: + row.policy = policy + session.add(row) + return policy + + def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False) -> list[str]: fields: list[str] = [] @@ -373,15 +538,13 @@ def _owner_context( campaign_id: str | None = None, owner_user_id: str | None = None, owner_group_id: str | None = None, -) -> tuple[Any | None, str | None, str | None]: - campaign = None +) -> tuple[CampaignMailPolicyContext | None, str | None, str | None]: + campaign_context = None if campaign_id: - campaign = session.get(_campaign_model(), campaign_id) - if campaign is None or campaign.tenant_id != tenant_id: - raise MailProfileError("Campaign not found for mail-server profile policy") - owner_user_id = campaign.owner_user_id - owner_group_id = campaign.owner_group_id - return campaign, owner_user_id, owner_group_id + campaign_context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id) + owner_user_id = campaign_context.owner_user_id + owner_group_id = campaign_context.owner_group_id + return campaign_context, owner_user_id, owner_group_id def effective_mail_profile_policy( @@ -399,22 +562,22 @@ def effective_mail_profile_policy( owner_user_id=owner_user_id, owner_group_id=owner_group_id, ) - tenant = session.get(Tenant, tenant_id) - if tenant is None: + tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id) + if tenant_policy is None: raise MailProfileError("Tenant not found for mail-server profile policy") policy = EffectiveMailProfilePolicy() - system_settings = get_system_settings(session) - _merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) - _merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant") + system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {} + _merge_policy(policy, system_policy, source="system", label="System", baseline=True) + _merge_policy(policy, tenant_policy, source="tenant", source_id=tenant_id, label="Tenant") if owner_user_id: - user = session.get(User, owner_user_id) - if user and user.tenant_id == tenant_id: - _merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user") + user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=owner_user_id) + if user_policy is not None: + _merge_policy(policy, user_policy, source="user", source_id=owner_user_id, label="Owner user") if owner_group_id: - group = session.get(Group, owner_group_id) - if group and group.tenant_id == tenant_id: - _merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group") + group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=owner_group_id) + if group_policy is not None: + _merge_policy(policy, group_policy, source="group", source_id=owner_group_id, label="Owner group") if campaign is not None: _merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign") return policy @@ -434,8 +597,8 @@ def effective_mail_profile_policy_for_scope( return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) if clean_scope == "system": policy = EffectiveMailProfilePolicy() - system_settings = get_system_settings(session) - _merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) + system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {} + _merge_policy(policy, system_policy, source="system", label="System", baseline=True) return policy if clean_scope == "tenant": return effective_mail_profile_policy(session, tenant_id=tenant_id) @@ -460,33 +623,31 @@ def parent_mail_profile_policy( scope_id: str | None = None, ) -> EffectiveMailProfilePolicy: clean_scope = _normalize_scope_type(scope_type) - tenant = session.get(Tenant, tenant_id) - if tenant is None: + tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id) + if tenant_policy is None: raise MailProfileError("Tenant not found for mail-server profile policy") policy = EffectiveMailProfilePolicy() - system_settings = get_system_settings(session) - _merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) + system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {} + _merge_policy(policy, system_policy, source="system", label="System", baseline=True) if clean_scope == "tenant": return policy - _merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant") + _merge_policy(policy, tenant_policy, source="tenant", source_id=tenant_id, label="Tenant") if clean_scope in {"user", "group"}: return policy if clean_scope != "campaign" or not scope_id: return policy - campaign = session.get(_campaign_model(), scope_id) - if campaign is None or campaign.tenant_id != tenant_id: - raise MailProfileError("Campaign policy not found") + campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) if campaign.owner_user_id: - user = session.get(User, campaign.owner_user_id) - if user and user.tenant_id == tenant_id: - _merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user") + user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=campaign.owner_user_id) + if user_policy is not None: + _merge_policy(policy, user_policy, source="user", source_id=campaign.owner_user_id, label="Owner user") if campaign.owner_group_id: - group = session.get(Group, campaign.owner_group_id) - if group and group.tenant_id == tenant_id: - _merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group") + group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=campaign.owner_group_id) + if group_policy is not None: + _merge_policy(policy, group_policy, source="group", source_id=campaign.owner_group_id, label="Owner group") return policy @@ -580,22 +741,18 @@ def _scope_tuple_for_create( target_id = clean_scope_id or user_id if not target_id: raise MailProfileError("User-scoped mail-server profiles require a user scope_id") - user = session.get(User, target_id) - if user is None or user.tenant_id != tenant_id: + if not _user_exists(session, tenant_id=tenant_id, user_id=target_id): raise MailProfileError("User scope is not part of the active tenant") return tenant_id, clean_scope, target_id if clean_scope == "group": if not clean_scope_id: raise MailProfileError("Group-scoped mail-server profiles require a group scope_id") - group = session.get(Group, clean_scope_id) - if group is None or group.tenant_id != tenant_id: + if not _group_exists(session, tenant_id=tenant_id, group_id=clean_scope_id): raise MailProfileError("Group scope is not part of the active tenant") return tenant_id, clean_scope, clean_scope_id if not clean_scope_id: raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id") - campaign = session.get(_campaign_model(), clean_scope_id) - if campaign is None or campaign.tenant_id != tenant_id: - raise MailProfileError("Campaign scope is not part of the active tenant") + _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=clean_scope_id) return tenant_id, clean_scope, clean_scope_id @@ -647,7 +804,7 @@ def _profile_visible_filter(tenant_id: str): return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id) -def _context_profile_clauses(campaign: Any, policy: EffectiveMailProfilePolicy) -> list[Any]: +def _context_profile_clauses(campaign: CampaignMailPolicyContext, policy: EffectiveMailProfilePolicy) -> list[Any]: clauses: list[Any] = [ MailServerProfile.scope_type == "system", and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"), @@ -674,7 +831,7 @@ def _profile_allowed_for_context( *, tenant_id: str, policy: EffectiveMailProfilePolicy, - campaign: Any | None = None, + campaign: CampaignMailPolicyContext | None = None, owner_user_id: str | None = None, owner_group_id: str | None = None, ) -> bool: @@ -711,9 +868,7 @@ def list_mail_server_profiles( campaign_id: str | None = None, ) -> list[MailServerProfile]: if campaign_id: - campaign = session.get(_campaign_model(), campaign_id) - if campaign is None or campaign.tenant_id != tenant_id: - raise MailProfileError("Campaign not found for mail-server profiles") + campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy))) if not include_inactive: @@ -762,9 +917,7 @@ def ensure_mail_profile_allowed_for_campaign( profile_id: str, require_active: bool = True, ) -> MailServerProfile: - campaign = session.get(_campaign_model(), campaign_id) - if campaign is None or campaign.tenant_id != tenant_id: - raise MailProfileError("Campaign not found for mail-server profile policy") + campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id) profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) if not _profile_allowed_for_context( @@ -1165,27 +1318,25 @@ def get_mail_profile_policy( ) -> dict[str, Any]: clean_scope = _normalize_scope_type(scope_type) if clean_scope == "system": - return _policy_from_settings(get_system_settings(session).settings or {}) + return _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {} if clean_scope == "tenant": - tenant = session.get(Tenant, scope_id or tenant_id) - if tenant is None or tenant.id != tenant_id: + policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=scope_id or tenant_id) + if policy is None: raise MailProfileError("Tenant policy not found") - return _policy_from_settings(tenant.settings or {}) + return policy if not scope_id: raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") if clean_scope == "user": - user = session.get(User, scope_id) - if user is None or user.tenant_id != tenant_id: + policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id) + if policy is None: raise MailProfileError("User policy not found") - return _policy_from_attr(user.mail_profile_policy) + return policy if clean_scope == "group": - group = session.get(Group, scope_id) - if group is None or group.tenant_id != tenant_id: + policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id) + if policy is None: raise MailProfileError("Group policy not found") - return _policy_from_attr(group.mail_profile_policy) - campaign = session.get(_campaign_model(), scope_id) - if campaign is None or campaign.tenant_id != tenant_id: - raise MailProfileError("Campaign policy not found") + return policy + campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) return _policy_from_attr(campaign.mail_profile_policy) @@ -1231,42 +1382,28 @@ def set_mail_profile_policy( normalized, ) if clean_scope == "system": - item = get_system_settings(session) - settings_payload = dict(item.settings or {}) - settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized - item.settings = settings_payload - session.add(item) - return normalized + return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system", scope_id=None, policy=normalized) if clean_scope == "tenant": - tenant = session.get(Tenant, scope_id or tenant_id) - if tenant is None or tenant.id != tenant_id: + if (scope_id and scope_id != tenant_id) or not _tenant_exists(session, tenant_id): raise MailProfileError("Tenant policy not found") - settings_payload = dict(tenant.settings or {}) - settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized - tenant.settings = settings_payload - session.add(tenant) - return normalized + return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id, policy=normalized) if not scope_id: raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") if clean_scope == "user": - user = session.get(User, scope_id) - if user is None or user.tenant_id != tenant_id: + if not _user_exists(session, tenant_id=tenant_id, user_id=scope_id): raise MailProfileError("User policy not found") - user.mail_profile_policy = normalized - session.add(user) - return normalized + return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id, policy=normalized) if clean_scope == "group": - group = session.get(Group, scope_id) - if group is None or group.tenant_id != tenant_id: + if not _group_exists(session, tenant_id=tenant_id, group_id=scope_id): raise MailProfileError("Group policy not found") - group.mail_profile_policy = normalized - session.add(group) - return normalized - campaign = session.get(_campaign_model(), scope_id) - if campaign is None or campaign.tenant_id != tenant_id: - raise MailProfileError("Campaign policy not found") - campaign.mail_profile_policy = normalized - session.add(campaign) + return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id, policy=normalized) + provider = _campaign_policy_provider() + if provider is None: + raise MailProfileError("Campaign module is not available") + try: + provider.set_campaign_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id, policy=normalized) + except ValueError as exc: + raise MailProfileError("Campaign policy not found") from exc return normalized @@ -1354,10 +1491,11 @@ def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str | def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]: - return [ - row.group_id - for row in session.query(UserGroupMembership).filter( - UserGroupMembership.tenant_id == tenant_id, - UserGroupMembership.user_id == user_id, - ) - ] + directory = _access_directory() + if directory is not None: + return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)] + rows = session.execute( + text("select group_id from user_group_memberships where tenant_id = :tenant_id and user_id = :user_id"), + {"tenant_id": tenant_id, "user_id": user_id}, + ).all() + return [row[0] for row in rows] diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index e061fa4..fc89465 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path +from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.db.base import Base from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata @@ -57,15 +58,23 @@ def _mail_router(context: ModuleContext): from govoplan_mail.backend.runtime import configure_runtime configure_runtime(settings=context.settings) + from fastapi import APIRouter from govoplan_mail.backend.router import router - return router + aggregate = APIRouter() + aggregate.include_router(router) + app_env = str(getattr(context.settings, "app_env", "dev")).lower() + if app_env == "dev" and bool(getattr(context.settings, "dev_mailbox_api_enabled", False)): + from govoplan_mail.backend.dev_router import router as dev_router + + aggregate.include_router(dev_router) + return aggregate manifest = ModuleManifest( id="mail", name="Mail", - version="0.1.4", + version="0.1.5", dependencies=("access",), optional_dependencies=(), permissions=PERMISSIONS, @@ -81,6 +90,20 @@ manifest = ModuleManifest( module_id="mail", metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + mail_models.MailServerProfile, + mail_models.MailProfilePolicy, + label="Mail", + ), + retirement_notes="Destructive retirement drops mail-owned database tables after the installer captures a database snapshot.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + mail_models.MailServerProfile, + mail_models.MailProfilePolicy, + label="Mail", + ), ), capability_factories={ "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), diff --git a/src/govoplan_mail/backend/migrations/versions/3d4e5f708192_mail_profile_policies.py b/src/govoplan_mail/backend/migrations/versions/3d4e5f708192_mail_profile_policies.py new file mode 100644 index 0000000..7e84884 --- /dev/null +++ b/src/govoplan_mail/backend/migrations/versions/3d4e5f708192_mail_profile_policies.py @@ -0,0 +1,50 @@ +"""mail profile policies + +Revision ID: 3d4e5f708192 +Revises: 1b2c3d4e5f70 +Create Date: 2026-07-06 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "3d4e5f708192" +down_revision = "1b2c3d4e5f70" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "mail_profile_policies" in inspector.get_table_names(): + return + op.create_table( + "mail_profile_policies", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("scope_type", sa.String(length=20), nullable=False), + sa.Column("scope_id", sa.String(length=36), nullable=True), + sa.Column("policy", sa.JSON(), 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"], name=op.f("fk_mail_profile_policies_tenant_id_tenants"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_profile_policies")), + sa.UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"), + ) + op.create_index(op.f("ix_mail_profile_policies_tenant_id"), "mail_profile_policies", ["tenant_id"], unique=False) + op.create_index(op.f("ix_mail_profile_policies_scope_type"), "mail_profile_policies", ["scope_type"], unique=False) + op.create_index(op.f("ix_mail_profile_policies_scope_id"), "mail_profile_policies", ["scope_id"], unique=False) + op.create_index("ix_mail_profile_policies_scope", "mail_profile_policies", ["scope_type", "scope_id"], unique=False) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "mail_profile_policies" not in inspector.get_table_names(): + return + op.drop_index("ix_mail_profile_policies_scope", table_name="mail_profile_policies") + op.drop_index(op.f("ix_mail_profile_policies_scope_id"), table_name="mail_profile_policies") + op.drop_index(op.f("ix_mail_profile_policies_scope_type"), table_name="mail_profile_policies") + op.drop_index(op.f("ix_mail_profile_policies_tenant_id"), table_name="mail_profile_policies") + op.drop_table("mail_profile_policies") diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index 85ee1e1..618f684 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -20,7 +20,7 @@ from govoplan_mail.backend.schemas import ( MailServerProfileUpdateRequest, MailSmtpTestRequest, ) -from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal, has_scope +from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope from govoplan_core.db.session import get_session from govoplan_mail.backend.mail_profiles import ( MailProfileError, diff --git a/webui/package.json b/webui/package.json index 5ebe7ad..d2c9e77 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "src/index.ts", @@ -14,7 +14,7 @@ "./styles/mail-profiles.css": "./src/styles/mail-profiles.css" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.4", + "@govoplan/core-webui": "^0.1.5", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0",