Prepare v0.1.0 release

This commit is contained in:
2026-06-24 20:00:38 +02:00
parent a9d16a2c89
commit 97015e5a01
10 changed files with 409 additions and 79 deletions

View File

@@ -198,6 +198,40 @@ def _policy_from_attr(value: Any) -> dict[str, Any]:
return normalize_mail_profile_policy(value if isinstance(value, dict) else {})
def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False) -> list[str]:
fields: list[str] = []
if _meaningful_allow_patterns(policy.get("allowed_profile_ids") or []):
fields.append("allowed_profile_ids")
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
if isinstance(policy.get(key), bool):
fields.append(key)
for key in ("smtp_credentials", "imap_credentials"):
credential = policy.get(key) or {}
if isinstance(credential, dict):
if isinstance(credential.get("inherit"), bool):
fields.append(f"{key}.inherit")
if isinstance(credential.get("allow_override"), bool):
fields.append(f"{key}.allow_override")
for kind in ("whitelist", "blacklist"):
rules = policy.get(kind) or {}
if isinstance(rules, dict):
for key in PROFILE_PATTERN_KEYS:
if _clean_string_list(rules.get(key, [])):
fields.append(f"{kind}.{key}")
if baseline and not fields:
fields.append("defaults")
return fields
def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
return {
"scope_type": source,
"scope_id": source_id,
"label": label,
"applied_fields": _mail_policy_source_fields(policy, baseline=baseline),
}
def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None:
inherit = data.get("inherit")
if isinstance(inherit, bool):
@@ -216,9 +250,9 @@ def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str,
current.allow_override = False
def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str) -> None:
def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str, source_id: str | None = None, label: str | None = None, baseline: bool = False) -> None:
normalized = normalize_mail_profile_policy(data)
policy.source_policies.append(normalized)
policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline))
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids:
policy.allowed_profile_id_sets.append(set(allowed_ids))
@@ -280,18 +314,18 @@ def effective_mail_profile_policy(
policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session)
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system")
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant")
_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")
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")
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=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")
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group")
if campaign is not None:
_merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign")
_merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign")
return policy
@@ -309,11 +343,11 @@ def parent_mail_profile_policy(
policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session)
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system")
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
if clean_scope == "tenant":
return policy
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant")
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant")
if clean_scope in {"user", "group"}:
return policy
@@ -325,11 +359,11 @@ def parent_mail_profile_policy(
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")
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=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")
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group")
return policy
@@ -454,7 +488,7 @@ def _ensure_scope_allows_profile_creation(
if scope_type == "campaign":
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id)
if not policy.allow_campaign_profiles:
raise MailProfileError("Campaign-scoped mail-server profiles are disabled by policy")
raise MailProfileError("Campaign-local mail profiles are disabled by policy")
return
if scope_type == "user":
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, owner_user_id=scope_id)
@@ -694,6 +728,17 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
def _inline_transport_configured(value: Any) -> bool:
if not isinstance(value, dict):
return False
if value.get("enabled") is True:
return True
for key in ("host", "username", "password"):
if str(value.get(key) or "").strip():
return True
return False
def assert_campaign_mail_policy_allows_json(
session: Session,
*,
@@ -737,6 +782,12 @@ def assert_campaign_mail_policy_allows_json(
return
smtp = server.get("smtp") if isinstance(server, dict) and isinstance(server.get("smtp"), dict) else None
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
if campaign_id:
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if not policy.allow_campaign_profiles and (_inline_transport_configured(smtp) or _inline_transport_configured(imap)):
raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
_assert_transport_values_allowed(policy, smtp, imap)
return
assert_mail_policy_allows_transport(
session,
tenant_id=tenant_id,

View File

@@ -30,6 +30,7 @@ from govoplan_mail.backend.mail_profiles import (
get_mail_server_profile,
imap_config_from_profile,
list_mail_server_profiles,
parent_mail_profile_policy,
profile_response_payload,
set_mail_profile_policy,
smtp_config_from_profile,
@@ -138,6 +139,12 @@ def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: st
return imap
def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
if scope_type == "system":
return None
return parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id).as_dict()
@router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles(
include_inactive: bool = False,
@@ -267,11 +274,19 @@ def read_mail_profile_policy(
try:
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
effective = None
effective_sources = []
if campaign_id:
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id).as_dict()
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
elif scope_type == "campaign" and scope_id:
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -296,9 +311,15 @@ def write_mail_profile_policy(
)
session.commit()
effective = None
effective_sources = []
if scope_type == "campaign" and scope_id:
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@@ -404,6 +425,7 @@ def list_profile_mailbox_messages(
host=result.host,
port=result.port,
security=result.security,
total_count=result.total_count,
messages=[_mailbox_summary_response(message) for message in result.messages],
)
except MailProfileError as exc:

View File

@@ -47,11 +47,21 @@ class MailProfilePolicyUpdateRequest(BaseModel):
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
class PolicySourceStepResponse(BaseModel):
scope_type: str
scope_id: str | None = None
label: str
applied_fields: list[str] = Field(default_factory=list)
class MailProfilePolicyResponse(BaseModel):
scope_type: MailProfileScope
scope_id: str | None = None
policy: dict[str, Any]
effective_policy: dict[str, Any] | None = None
parent_policy: dict[str, Any] | None = None
effective_policy_sources: list[PolicySourceStepResponse] = Field(default_factory=list)
parent_policy_sources: list[PolicySourceStepResponse] = Field(default_factory=list)
class MailServerProfileCreateRequest(BaseModel):
@@ -160,6 +170,7 @@ class MailMailboxMessageListResponse(BaseModel):
host: str | None = None
port: int | None = None
security: str | None = None
total_count: int = 0
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)

View File

@@ -110,6 +110,7 @@ class ImapMailboxMessageListResult:
security: str
folder: str
messages: list[ImapMailboxMessageSummary]
total_count: int
@dataclass(frozen=True, slots=True)
@@ -512,7 +513,7 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
)
for record in records[:limit]
]
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages)
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records))
client = _open_imap(imap_config)
try:
@@ -526,7 +527,7 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
for uid in reversed(uids[-limit:]):
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages)
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids))
finally:
try:
client.logout()