Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent 3743b23613
commit 5f800a24db
18 changed files with 524 additions and 123 deletions

View File

@@ -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:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -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?

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

View File

@@ -50,7 +50,15 @@ Frontend package:
@govoplan/mail-webui @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/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.4", "@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-mail" name = "govoplan-mail"
version = "0.1.4" version = "0.1.5"
description = "GovOPlaN mail module with backend and WebUI integration." description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.4", "govoplan-core>=0.1.5",
"pydantic>=2,<3", "pydantic>=2,<3",
"redis>=5,<6", "redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",

View File

@@ -41,6 +41,12 @@ class MailCampaignCapability:
append_message_to_sent = staticmethod(append_message_to_sent) append_message_to_sent = staticmethod(append_message_to_sent)
send_email_message = staticmethod(send_email_message) 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: def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
configure_runtime(settings=context.settings) configure_runtime(settings=context.settings)

View File

@@ -40,3 +40,16 @@ class MailServerProfile(Base, TimestampMixin):
tenant: Mapped["Tenant"] = relationship() 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)

View File

@@ -5,7 +5,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field 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 ( from govoplan_mail.backend.dev.mock_mailbox import (
clear_records, clear_records,
get_failures, get_failures,

View File

@@ -2,17 +2,23 @@ from __future__ import annotations
import copy import copy
import fnmatch import fnmatch
import json
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Iterable from typing import Any, Iterable
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.admin.governance import get_system_settings from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership from govoplan_core.core.campaigns import (
from govoplan_mail.backend.db.models import MailServerProfile 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_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret 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} 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) @dataclass(slots=True)
class EffectiveCredentialPolicy: class EffectiveCredentialPolicy:
inherit: bool = True 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 {}) 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]: def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False) -> list[str]:
fields: list[str] = [] fields: list[str] = []
@@ -373,15 +538,13 @@ def _owner_context(
campaign_id: str | None = None, campaign_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> tuple[Any | None, str | None, str | None]: ) -> tuple[CampaignMailPolicyContext | None, str | None, str | None]:
campaign = None campaign_context = None
if campaign_id: if campaign_id:
campaign = session.get(_campaign_model(), campaign_id) campaign_context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if campaign is None or campaign.tenant_id != tenant_id: owner_user_id = campaign_context.owner_user_id
raise MailProfileError("Campaign not found for mail-server profile policy") owner_group_id = campaign_context.owner_group_id
owner_user_id = campaign.owner_user_id return campaign_context, owner_user_id, owner_group_id
owner_group_id = campaign.owner_group_id
return campaign, owner_user_id, owner_group_id
def effective_mail_profile_policy( def effective_mail_profile_policy(
@@ -399,22 +562,22 @@ def effective_mail_profile_policy(
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
owner_group_id=owner_group_id, owner_group_id=owner_group_id,
) )
tenant = session.get(Tenant, tenant_id) tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id)
if tenant is None: if tenant_policy is None:
raise MailProfileError("Tenant not found for mail-server profile policy") raise MailProfileError("Tenant not found for mail-server profile policy")
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session) system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) _merge_policy(policy, system_policy, source="system", label="System", baseline=True)
_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 owner_user_id: if owner_user_id:
user = session.get(User, owner_user_id) user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=owner_user_id)
if user and user.tenant_id == tenant_id: if user_policy is not None:
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user") _merge_policy(policy, user_policy, source="user", source_id=owner_user_id, label="Owner user")
if owner_group_id: if owner_group_id:
group = session.get(Group, owner_group_id) group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=owner_group_id)
if group and group.tenant_id == tenant_id: if group_policy is not None:
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group") _merge_policy(policy, group_policy, source="group", source_id=owner_group_id, label="Owner group")
if campaign is not None: if campaign is not None:
_merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign") _merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign")
return policy 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) return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if clean_scope == "system": if clean_scope == "system":
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session) system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) _merge_policy(policy, system_policy, source="system", label="System", baseline=True)
return policy return policy
if clean_scope == "tenant": if clean_scope == "tenant":
return effective_mail_profile_policy(session, tenant_id=tenant_id) return effective_mail_profile_policy(session, tenant_id=tenant_id)
@@ -460,33 +623,31 @@ def parent_mail_profile_policy(
scope_id: str | None = None, scope_id: str | None = None,
) -> EffectiveMailProfilePolicy: ) -> EffectiveMailProfilePolicy:
clean_scope = _normalize_scope_type(scope_type) clean_scope = _normalize_scope_type(scope_type)
tenant = session.get(Tenant, tenant_id) tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id)
if tenant is None: if tenant_policy is None:
raise MailProfileError("Tenant not found for mail-server profile policy") raise MailProfileError("Tenant not found for mail-server profile policy")
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session) system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) _merge_policy(policy, system_policy, source="system", label="System", baseline=True)
if clean_scope == "tenant": if clean_scope == "tenant":
return policy 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"}: if clean_scope in {"user", "group"}:
return policy return policy
if clean_scope != "campaign" or not scope_id: if clean_scope != "campaign" or not scope_id:
return policy return policy
campaign = session.get(_campaign_model(), scope_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
if campaign.owner_user_id: if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id) user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=campaign.owner_user_id)
if user and user.tenant_id == tenant_id: if user_policy is not None:
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user") _merge_policy(policy, user_policy, source="user", source_id=campaign.owner_user_id, label="Owner user")
if campaign.owner_group_id: if campaign.owner_group_id:
group = session.get(Group, campaign.owner_group_id) group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=campaign.owner_group_id)
if group and group.tenant_id == tenant_id: if group_policy is not None:
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group") _merge_policy(policy, group_policy, source="group", source_id=campaign.owner_group_id, label="Owner group")
return policy return policy
@@ -580,22 +741,18 @@ def _scope_tuple_for_create(
target_id = clean_scope_id or user_id target_id = clean_scope_id or user_id
if not target_id: if not target_id:
raise MailProfileError("User-scoped mail-server profiles require a user scope_id") raise MailProfileError("User-scoped mail-server profiles require a user scope_id")
user = session.get(User, target_id) if not _user_exists(session, tenant_id=tenant_id, user_id=target_id):
if user is None or user.tenant_id != tenant_id:
raise MailProfileError("User scope is not part of the active tenant") raise MailProfileError("User scope is not part of the active tenant")
return tenant_id, clean_scope, target_id return tenant_id, clean_scope, target_id
if clean_scope == "group": if clean_scope == "group":
if not clean_scope_id: if not clean_scope_id:
raise MailProfileError("Group-scoped mail-server profiles require a group scope_id") raise MailProfileError("Group-scoped mail-server profiles require a group scope_id")
group = session.get(Group, clean_scope_id) if not _group_exists(session, tenant_id=tenant_id, group_id=clean_scope_id):
if group is None or group.tenant_id != tenant_id:
raise MailProfileError("Group scope is not part of the active tenant") raise MailProfileError("Group scope is not part of the active tenant")
return tenant_id, clean_scope, clean_scope_id return tenant_id, clean_scope, clean_scope_id
if not clean_scope_id: if not clean_scope_id:
raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id") raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id")
campaign = session.get(_campaign_model(), clean_scope_id) _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=clean_scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign scope is not part of the active tenant")
return tenant_id, clean_scope, 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) 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] = [ clauses: list[Any] = [
MailServerProfile.scope_type == "system", MailServerProfile.scope_type == "system",
and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"), and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"),
@@ -674,7 +831,7 @@ def _profile_allowed_for_context(
*, *,
tenant_id: str, tenant_id: str,
policy: EffectiveMailProfilePolicy, policy: EffectiveMailProfilePolicy,
campaign: Any | None = None, campaign: CampaignMailPolicyContext | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> bool: ) -> bool:
@@ -711,9 +868,7 @@ def list_mail_server_profiles(
campaign_id: str | None = None, campaign_id: str | None = None,
) -> list[MailServerProfile]: ) -> list[MailServerProfile]:
if campaign_id: if campaign_id:
campaign = session.get(_campaign_model(), campaign_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profiles")
policy = effective_mail_profile_policy(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))) query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy)))
if not include_inactive: if not include_inactive:
@@ -762,9 +917,7 @@ def ensure_mail_profile_allowed_for_campaign(
profile_id: str, profile_id: str,
require_active: bool = True, require_active: bool = True,
) -> MailServerProfile: ) -> MailServerProfile:
campaign = session.get(_campaign_model(), campaign_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy")
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active) 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) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
if not _profile_allowed_for_context( if not _profile_allowed_for_context(
@@ -1165,27 +1318,25 @@ def get_mail_profile_policy(
) -> dict[str, Any]: ) -> dict[str, Any]:
clean_scope = _normalize_scope_type(scope_type) clean_scope = _normalize_scope_type(scope_type)
if clean_scope == "system": 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": if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id) policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id: if policy is None:
raise MailProfileError("Tenant policy not found") raise MailProfileError("Tenant policy not found")
return _policy_from_settings(tenant.settings or {}) return policy
if not scope_id: if not scope_id:
raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id")
if clean_scope == "user": if clean_scope == "user":
user = session.get(User, scope_id) policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id)
if user is None or user.tenant_id != tenant_id: if policy is None:
raise MailProfileError("User policy not found") raise MailProfileError("User policy not found")
return _policy_from_attr(user.mail_profile_policy) return policy
if clean_scope == "group": if clean_scope == "group":
group = session.get(Group, scope_id) policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id)
if group is None or group.tenant_id != tenant_id: if policy is None:
raise MailProfileError("Group policy not found") raise MailProfileError("Group policy not found")
return _policy_from_attr(group.mail_profile_policy) return policy
campaign = session.get(_campaign_model(), scope_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
return _policy_from_attr(campaign.mail_profile_policy) return _policy_from_attr(campaign.mail_profile_policy)
@@ -1231,42 +1382,28 @@ def set_mail_profile_policy(
normalized, normalized,
) )
if clean_scope == "system": if clean_scope == "system":
item = get_system_settings(session) return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system", scope_id=None, policy=normalized)
settings_payload = dict(item.settings or {})
settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized
item.settings = settings_payload
session.add(item)
return normalized
if clean_scope == "tenant": if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id) if (scope_id and scope_id != tenant_id) or not _tenant_exists(session, tenant_id):
if tenant is None or tenant.id != tenant_id:
raise MailProfileError("Tenant policy not found") raise MailProfileError("Tenant policy not found")
settings_payload = dict(tenant.settings or {}) return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id, policy=normalized)
settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized
tenant.settings = settings_payload
session.add(tenant)
return normalized
if not scope_id: if not scope_id:
raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id")
if clean_scope == "user": if clean_scope == "user":
user = session.get(User, scope_id) if not _user_exists(session, tenant_id=tenant_id, user_id=scope_id):
if user is None or user.tenant_id != tenant_id:
raise MailProfileError("User policy not found") raise MailProfileError("User policy not found")
user.mail_profile_policy = normalized return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id, policy=normalized)
session.add(user)
return normalized
if clean_scope == "group": if clean_scope == "group":
group = session.get(Group, scope_id) if not _group_exists(session, tenant_id=tenant_id, group_id=scope_id):
if group is None or group.tenant_id != tenant_id:
raise MailProfileError("Group policy not found") raise MailProfileError("Group policy not found")
group.mail_profile_policy = normalized return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id, policy=normalized)
session.add(group) provider = _campaign_policy_provider()
return normalized if provider is None:
campaign = session.get(_campaign_model(), scope_id) raise MailProfileError("Campaign module is not available")
if campaign is None or campaign.tenant_id != tenant_id: try:
raise MailProfileError("Campaign policy not found") provider.set_campaign_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id, policy=normalized)
campaign.mail_profile_policy = normalized except ValueError as exc:
session.add(campaign) raise MailProfileError("Campaign policy not found") from exc
return normalized 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]: def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]:
return [ directory = _access_directory()
row.group_id if directory is not None:
for row in session.query(UserGroupMembership).filter( return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
UserGroupMembership.tenant_id == tenant_id, rows = session.execute(
UserGroupMembership.user_id == user_id, 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]

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path 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.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata 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 from govoplan_mail.backend.runtime import configure_runtime
configure_runtime(settings=context.settings) configure_runtime(settings=context.settings)
from fastapi import APIRouter
from govoplan_mail.backend.router import router 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( manifest = ModuleManifest(
id="mail", id="mail",
name="Mail", name="Mail",
version="0.1.4", version="0.1.5",
dependencies=("access",), dependencies=("access",),
optional_dependencies=(), optional_dependencies=(),
permissions=PERMISSIONS, permissions=PERMISSIONS,
@@ -81,6 +90,20 @@ manifest = ModuleManifest(
module_id="mail", module_id="mail",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), 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={ capability_factories={
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),

View File

@@ -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")

View File

@@ -20,7 +20,7 @@ from govoplan_mail.backend.schemas import (
MailServerProfileUpdateRequest, MailServerProfileUpdateRequest,
MailSmtpTestRequest, 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_core.db.session import get_session
from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.mail_profiles import (
MailProfileError, MailProfileError,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css" "./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.4", "@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",