Release v0.1.5
This commit is contained in:
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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:
|
||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal 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?
|
||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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:
|
||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal 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:
|
||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal 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:
|
||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Issue
|
||||
|
||||
Closes #
|
||||
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
Follow-up issues:
|
||||
10
README.md
10
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/`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user