From d5d5b896036f48db3467546397c6d07a9dac6456 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 12:51:22 +0200 Subject: [PATCH] chore: sync GovOPlaN module split state --- README.md | 5 + docs/MAIL_PROTOCOL_ROADMAP.md | 50 ++ package.json | 2 +- src/govoplan_mail/backend/db/models.py | 8 +- src/govoplan_mail/backend/dev_router.py | 2 +- src/govoplan_mail/backend/documentation.py | 259 ++++++ src/govoplan_mail/backend/mail_profiles.py | 29 +- src/govoplan_mail/backend/manifest.py | 43 +- .../3d4e5f708192_mail_profile_policies.py | 6 +- src/govoplan_mail/backend/router.py | 416 +++++++++- src/govoplan_mail/backend/schemas.py | 17 +- src/govoplan_mail/backend/sending/imap.py | 143 +++- webui/package.json | 2 +- webui/src/api/mail.ts | 42 +- .../features/mail/MailProfileManagement.tsx | 765 +++++++++++------- webui/src/features/mail/MailboxPage.tsx | 218 ++--- .../src/features/mail/mailPolicyValidation.ts | 52 +- webui/src/i18n/generatedTranslations.ts | 398 +++++++++ webui/src/index.ts | 1 - webui/src/module.ts | 17 +- webui/src/styles/mail-profiles.css | 31 - 21 files changed, 1955 insertions(+), 551 deletions(-) create mode 100644 docs/MAIL_PROTOCOL_ROADMAP.md create mode 100644 src/govoplan_mail/backend/documentation.py create mode 100644 webui/src/i18n/generatedTranslations.ts diff --git a/README.md b/README.md index 0619315..486d7db 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,11 @@ 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. +POP3 and JMAP are deferred. The protocol decision is documented in +[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize +SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3 +only for explicit legacy-download requirements. + Platform RBAC and governance rules are documented in `govoplan-core/docs/`. ## Release packaging diff --git a/docs/MAIL_PROTOCOL_ROADMAP.md b/docs/MAIL_PROTOCOL_ROADMAP.md new file mode 100644 index 0000000..54af03e --- /dev/null +++ b/docs/MAIL_PROTOCOL_ROADMAP.md @@ -0,0 +1,50 @@ +# Mail Protocol Roadmap + +GovOPlaN Mail currently focuses on SMTP sending and IMAP mailbox access. POP3 +and JMAP are deferred until the IMAP mailbox MVP is stable. + +## Current Baseline + +- SMTP is the send protocol. +- IMAP is the read/append protocol. +- Mail profile policy, encrypted credentials, mailbox folder parsing, test + buttons, and read-only mailbox UI are built around SMTP and IMAP. + +This baseline matches the first production use case: send campaign mail, append +sent copies when configured, and inspect mailboxes read-only. + +## JMAP + +JMAP is the preferred future sync/search protocol where target mail servers +support it. + +Reasons: + +- HTTP/JSON transport fits the platform API style better than stateful IMAP +- efficient mailbox state sync and changes endpoints +- modern search and thread models +- better fit for browser-facing mailbox UX through a server proxy + +JMAP should be added only after: + +- the IMAP mailbox MVP has stable folder/message pagination behavior +- mail profile policy can express protocol-specific availability +- mailbox UI can handle protocol-neutral folder/message DTOs +- test infrastructure includes at least one reliable JMAP server target + +## POP3 + +POP3 should remain legacy-only. + +Add it only when a concrete deployment requires mailbox download from a server +that cannot offer IMAP or JMAP. POP3 is a poor fit for the normal GovOPlaN +mailbox UX because it has limited folder, sync, and server-side state semantics. + +If implemented, POP3 should be scoped to explicit download/import workflows, not +general mailbox browsing. + +## Decision + +Do not add POP3 or JMAP now. Stabilize SMTP/IMAP first, design protocol-neutral +mailbox DTOs, then prefer JMAP for modern servers and reserve POP3 for explicit +legacy download requirements. diff --git a/package.json b/package.json index ce65467..53a92e0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ ], "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/src/govoplan_mail/backend/db/models.py b/src/govoplan_mail/backend/db/models.py index 4224f1f..074dc90 100644 --- a/src/govoplan_mail/backend/db/models.py +++ b/src/govoplan_mail/backend/db/models.py @@ -21,7 +21,7 @@ class MailServerProfile(Base, TimestampMixin): ) 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) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=True, index=True) scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True) scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) @@ -34,8 +34,8 @@ class MailServerProfile(Base, TimestampMixin): imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) imap_username: Mapped[str | None] = mapped_column(String(320)) imap_password_encrypted: Mapped[str | None] = mapped_column(Text) - created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) - updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) + updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) tenant: Mapped["Tenant"] = relationship() @@ -48,7 +48,7 @@ class MailProfilePolicy(Base, TimestampMixin): ) 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) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=True, index=True) scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) diff --git a/src/govoplan_mail/backend/dev_router.py b/src/govoplan_mail/backend/dev_router.py index 71f2140..cf9af8b 100644 --- a/src/govoplan_mail/backend/dev_router.py +++ b/src/govoplan_mail/backend/dev_router.py @@ -5,7 +5,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, Field -from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_scope +from govoplan_access.auth import ApiPrincipal, require_scope from govoplan_mail.backend.dev.mock_mailbox import ( clear_records, get_failures, diff --git a/src/govoplan_mail/backend/documentation.py b/src/govoplan_mail/backend/documentation.py new file mode 100644 index 0000000..a9ec182 --- /dev/null +++ b/src/govoplan_mail/backend/documentation.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_core.core.modules import DocumentationContext, DocumentationLink, DocumentationTopic +from govoplan_mail.backend.mail_profiles import MailProfileError, effective_mail_profile_policy_for_scope + +MAIL_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read") + + +def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]: + topic = _tenant_mail_policy_topic(context) + return (topic,) if topic is not None else () + + +def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTopic | None: + principal = context.principal + tenant_id = str(getattr(principal, "tenant_id", "") or "") + if not tenant_id: + return None + if context.documentation_type == "admin" and not _has_any_scope(principal, MAIL_POLICY_DOC_SCOPES): + return None + session = context.session + if not isinstance(session, Session): + return None + + try: + policy = effective_mail_profile_policy_for_scope(session, tenant_id=tenant_id, scope_type="tenant") + except MailProfileError as exc: + return DocumentationTopic( + id="mail.tenant-profile-policy-unavailable", + title="Mail server policy could not be evaluated", + summary="Mail profile documentation is installed, but the tenant-level effective policy could not be read for this request.", + body=str(exc), + layer="available", + documentation_types=(context.documentation_type,), + source_module_id="mail", + order=41, + links=(_mail_policy_api_link(),), + metadata={"error_type": type(exc).__name__}, + ) + + effective = policy.as_dict() + if context.documentation_type == "user": + summary, body = _mail_policy_user_text(effective) + return DocumentationTopic( + id="mail.tenant-profile-policy-user", + title="Choosing a mail server", + summary=summary, + body=body, + layer="configured", + documentation_types=("user",), + audience=("mail_user", "campaign_user"), + order=40, + links=( + DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"), + ), + related_modules=("campaigns",), + unlocks=("Campaigns can use these mail choices when the campaign module is available.",), + configuration_keys=("mail_profile_policy",), + i18n_key="mail.topic.tenant_profile_policy_user", + translations=_user_mail_policy_translations(effective), + source_module_id="mail", + metadata=_mail_policy_user_metadata(effective), + ) + + summary, body = _mail_policy_admin_text(effective, source_count=len(policy.source_policies)) + return DocumentationTopic( + id="mail.tenant-profile-policy-admin", + title="Mail server choices for this tenant", + summary=summary, + body=body, + layer="configured", + documentation_types=("admin",), + audience=("tenant_admin", "mail_admin", "campaign_admin"), + order=40, + links=( + DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"), + _mail_policy_api_link(), + DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"), + ), + related_modules=("campaigns",), + unlocks=("Campaign delivery can use reusable mail profiles when govoplan-campaign is installed.",), + configuration_keys=("mail_profile_policy",), + i18n_key="mail.topic.tenant_profile_policy_admin", + source_module_id="mail", + metadata=_mail_policy_metadata(effective, source_count=len(policy.source_policies)), + ) + + +def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tuple[str, str]: + allowed_profile_ids = policy.get("allowed_profile_ids") + lower_scopes = _allowed_lower_scopes(policy) + approved_profile_limit = isinstance(allowed_profile_ids, list) + pattern_counts = _pattern_counts(policy) + locked_limit_count = sum(1 for value in _lower_limit_values(policy) if value is False) + + if approved_profile_limit and not lower_scopes: + summary = "This tenant is in approved-profile mode: users can choose configured mail profiles, but lower scopes cannot bring arbitrary SMTP or IMAP servers." + elif approved_profile_limit: + summary = "This tenant limits mail sending to approved profile ids, while selected lower scopes can still define profiles within policy limits." + elif lower_scopes: + summary = "This tenant permits lower-scope mail profiles subject to the effective host, sender, recipient, and credential policy." + else: + summary = "This tenant uses centrally managed mail profiles; lower-scope profile creation is disabled by the effective policy." + + approved_line = _approved_profile_line(allowed_profile_ids) + lower_scope_line = _lower_scope_line(lower_scopes) + credential_line = _credential_line(policy) + pattern_line = f"Allow-list pattern groups: {pattern_counts['whitelist']}. Deny-list patterns: {pattern_counts['blacklist']}." + lower_limit_line = f"Locked lower-level limits: {locked_limit_count}. Policy sources applied: {source_count}." + body = "\n".join([approved_line, lower_scope_line, credential_line, pattern_line, lower_limit_line]) + return summary, body + + +def _mail_policy_user_text(policy: dict[str, Any]) -> tuple[str, str]: + allowed_profile_ids = policy.get("allowed_profile_ids") + lower_scopes = _allowed_lower_scopes(policy) + approved_profile_limit = isinstance(allowed_profile_ids, list) + + if approved_profile_limit and not lower_scopes: + summary = "You can choose from approved mail servers, but you cannot add your own mail server for this tenant." + body = "This is set by tenant policy. If the mail server you need is not offered, ask an administrator to add it as an approved mail profile." + elif approved_profile_limit: + summary = "You can use approved mail servers. Some local mail-server settings may also be allowed, depending on where you work." + body = "The available choices are limited by tenant policy. If you are working in a user, group, or campaign area that allows local settings, GovOPlaN still checks the server, sender, recipient, and credential rules." + elif lower_scopes: + summary = "You may be able to add a mail server in selected areas, as long as it follows the active tenant rules." + body = "GovOPlaN checks mail server settings before they are used. If a setting is blocked, it usually means the tenant has limited hosts, senders, recipients, or credentials." + else: + summary = "Mail servers are managed centrally for this tenant." + body = "You cannot add a personal, group, or campaign mail server here. Choose one of the configured options or ask an administrator to add another approved profile." + return summary, body + + +def _user_mail_policy_translations(policy: dict[str, Any]) -> dict[str, dict[str, str]]: + allowed_profile_ids = policy.get("allowed_profile_ids") + lower_scopes = _allowed_lower_scopes(policy) + approved_profile_limit = isinstance(allowed_profile_ids, list) + if approved_profile_limit and not lower_scopes: + return { + "de": { + "title": "Mailserver auswaehlen", + "summary": "Sie koennen aus freigegebenen Mailservern waehlen, aber keinen eigenen Mailserver fuer diesen Tenant eintragen.", + "body": "Das wird durch die Tenant-Regeln festgelegt. Wenn der benoetigte Mailserver nicht angeboten wird, bitten Sie eine Administratorin oder einen Administrator, ihn als freigegebenes Mailprofil anzulegen.", + }, + } + if approved_profile_limit: + return { + "de": { + "title": "Mailserver auswaehlen", + "summary": "Sie koennen freigegebene Mailserver nutzen. In manchen Bereichen koennen zusaetzliche lokale Einstellungen erlaubt sein.", + "body": "Die Auswahl wird durch Tenant-Regeln begrenzt. Auch wenn lokale Einstellungen erlaubt sind, prueft GovOPlaN Server, Absender, Empfaenger und Zugangsdaten.", + }, + } + if lower_scopes: + return { + "de": { + "title": "Mailserver auswaehlen", + "summary": "In bestimmten Bereichen koennen Sie einen Mailserver eintragen, solange die aktiven Tenant-Regeln eingehalten werden.", + "body": "GovOPlaN prueft Mailserver-Einstellungen, bevor sie verwendet werden. Wenn eine Einstellung blockiert wird, begrenzen die Tenant-Regeln meist Server, Absender, Empfaenger oder Zugangsdaten.", + }, + } + return { + "de": { + "title": "Mailserver auswaehlen", + "summary": "Mailserver werden fuer diesen Tenant zentral verwaltet.", + "body": "Sie koennen hier keinen persoenlichen, Gruppen- oder Kampagnen-Mailserver eintragen. Waehlen Sie eine konfigurierte Option oder bitten Sie eine Administratorin oder einen Administrator um ein weiteres freigegebenes Profil.", + }, + } + + +def _approved_profile_line(allowed_profile_ids: Any) -> str: + if isinstance(allowed_profile_ids, list): + count = len(allowed_profile_ids) + if count == 0: + return "Approved profiles: the active policy currently allows no reusable profile ids." + return f"Approved profiles: users are limited to {count} reusable profile id(s) selected by policy." + return "Approved profiles: no explicit approved-profile list is active." + + +def _lower_scope_line(lower_scopes: tuple[str, ...]) -> str: + if not lower_scopes: + return "Lower scopes: user, group, and campaign-local profile creation are disabled." + return "Lower scopes allowed to define profiles: " + ", ".join(lower_scopes) + "." + + +def _credential_line(policy: dict[str, Any]) -> str: + smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True)) + imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True)) + return f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; IMAP {'inherits' if imap_inherit else 'requires local credentials'}." + + +def _allowed_lower_scopes(policy: dict[str, Any]) -> tuple[str, ...]: + scopes: list[str] = [] + if policy.get("allow_user_profiles") is not False: + scopes.append("user") + if policy.get("allow_group_profiles") is not False: + scopes.append("group") + if policy.get("allow_campaign_profiles") is not False: + scopes.append("campaign") + return tuple(scopes) + + +def _pattern_counts(policy: dict[str, Any]) -> dict[str, int]: + return { + "whitelist": _pattern_count(policy.get("whitelist")), + "blacklist": _pattern_count(policy.get("blacklist")), + } + + +def _pattern_count(payload: Any) -> int: + if not isinstance(payload, dict): + return 0 + return sum(len(value) for value in payload.values() if isinstance(value, list)) + + +def _lower_limit_values(policy: dict[str, Any]) -> tuple[bool, ...]: + lower_limits = policy.get("allow_lower_level_limits") + if not isinstance(lower_limits, dict): + return () + return tuple(value for value in lower_limits.values() if isinstance(value, bool)) + + +def _mail_policy_metadata(policy: dict[str, Any], *, source_count: int) -> dict[str, Any]: + allowed_profile_ids = policy.get("allowed_profile_ids") + pattern_counts = _pattern_counts(policy) + return { + "approved_profile_limit_active": isinstance(allowed_profile_ids, list), + "approved_profile_count": len(allowed_profile_ids) if isinstance(allowed_profile_ids, list) else None, + "lower_scopes_allowed": list(_allowed_lower_scopes(policy)), + "whitelist_pattern_count": pattern_counts["whitelist"], + "blacklist_pattern_count": pattern_counts["blacklist"], + "locked_lower_level_limit_count": sum(1 for value in _lower_limit_values(policy) if value is False), + "policy_source_count": source_count, + } + + +def _mail_policy_user_metadata(policy: dict[str, Any]) -> dict[str, Any]: + allowed_profile_ids = policy.get("allowed_profile_ids") + return { + "approved_profile_limit_active": isinstance(allowed_profile_ids, list), + "can_add_local_mail_server": bool(_allowed_lower_scopes(policy)), + "lower_scopes_allowed": list(_allowed_lower_scopes(policy)), + } + + +def _has_any_scope(principal: object | None, scopes: tuple[str, ...]) -> bool: + has = getattr(principal, "has", None) + if callable(has): + return any(bool(has(scope)) for scope in scopes) + principal_scopes = set(getattr(principal, "scopes", ()) or ()) + return "*" in principal_scopes or any(scope in principal_scopes for scope in scopes) + + +def _mail_policy_api_link() -> DocumentationLink: + return DocumentationLink(label="Tenant mail policy API", href="/api/v1/mail/policies/tenant", kind="api") diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index 26daf7c..2caf912 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -17,6 +17,7 @@ from govoplan_core.core.campaigns import ( CampaignMailPolicyContext, CampaignMailPolicyContextProvider, ) +from govoplan_core.core.policy import policy_source_step 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 @@ -280,12 +281,12 @@ def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: s 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() + row = session.execute(text("select settings from tenancy_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"}: + if table_name not in {"access_users", "access_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"), @@ -303,7 +304,7 @@ def _user_exists(session: Session, *, tenant_id: str, user_id: str) -> bool: 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 + return _legacy_subject_policy(session, table_name="access_users", tenant_id=tenant_id, scope_id=user_id) is not None def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool: @@ -311,7 +312,7 @@ def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool: 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 + return _legacy_subject_policy(session, table_name="access_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]: @@ -355,10 +356,10 @@ def _legacy_mail_profile_policy( 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) + legacy = _legacy_subject_policy(session, table_name="access_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) + legacy = _legacy_subject_policy(session, table_name="access_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) @@ -463,13 +464,13 @@ def _mail_policy_source_policy(policy: dict[str, Any], *, baseline: bool = False 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), - "policy": _mail_policy_source_policy(policy, baseline=baseline), - } + return policy_source_step( + source, + label, + source_id, + applied_fields=_mail_policy_source_fields(policy, baseline=baseline), + policy=_mail_policy_source_policy(policy, baseline=baseline), + ) def _merge_credential_policy( @@ -1495,7 +1496,7 @@ def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> lis 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"), + text("select group_id from access_user_group_memberships where tenant_id = :tenant_id and user_id = :user_id"), {"tenant_id": tenant_id, "user_id": user_id}, ).all() return [row[0] for row in rows] diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index c104a5f..0b99c78 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -3,8 +3,20 @@ 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.core.modules import ( + DocumentationCondition, + DocumentationLink, + DocumentationTopic, + FrontendModule, + MigrationSpec, + ModuleContext, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) from govoplan_core.db.base import Base +from govoplan_mail.backend.documentation import documentation_topics from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata @@ -108,6 +120,35 @@ manifest = ModuleManifest( capability_factories={ "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), }, + documentation=( + DocumentationTopic( + id="mail.profiles-and-policy", + title="Mail profiles and policy hierarchy", + summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.", + body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define their own mail-server settings. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.", + layer="configured", + documentation_types=("admin",), + audience=("tenant_admin", "mail_admin", "campaign_admin"), + order=39, + i18n_key="mail.topic.profiles_and_policy", + conditions=( + DocumentationCondition( + required_modules=("mail",), + any_scopes=("mail:profile:read", "admin:policies:read", "system:settings:read"), + configuration_keys=("mail_profile_policy",), + ), + ), + links=( + DocumentationLink(label="Mail profiles", href="/api/v1/mail/profiles", kind="api"), + DocumentationLink(label="Tenant mail policy", href="/api/v1/mail/policies/tenant", kind="api"), + DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"), + ), + related_modules=("campaigns",), + unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",), + configuration_keys=("mail_profile_policy",), + ), + ), + documentation_providers=(documentation_topics,), ) diff --git a/src/govoplan_mail/backend/migrations/versions/3d4e5f708192_mail_profile_policies.py b/src/govoplan_mail/backend/migrations/versions/3d4e5f708192_mail_profile_policies.py index 7e84884..f364ae4 100644 --- a/src/govoplan_mail/backend/migrations/versions/3d4e5f708192_mail_profile_policies.py +++ b/src/govoplan_mail/backend/migrations/versions/3d4e5f708192_mail_profile_policies.py @@ -1,7 +1,7 @@ """mail profile policies Revision ID: 3d4e5f708192 -Revises: 1b2c3d4e5f70 +Revises: 2e3f4a5b6c7d Create Date: 2026-07-06 00:00:00.000000 """ from __future__ import annotations @@ -11,7 +11,7 @@ import sqlalchemy as sa revision = "3d4e5f708192" -down_revision = "1b2c3d4e5f70" +down_revision = "2e3f4a5b6c7d" branch_labels = None depends_on = None @@ -29,7 +29,7 @@ def upgrade() -> None: 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.ForeignKeyConstraint(["tenant_id"], ["tenancy_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"), ) diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index 618f684..db85384 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -1,6 +1,8 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session from govoplan_mail.backend.schemas import ( MailConnectionTestResponse, @@ -14,13 +16,23 @@ from govoplan_mail.backend.schemas import ( MailMailboxMessageSummaryResponse, MailProfilePolicyResponse, MailProfilePolicyUpdateRequest, + MailSettingsDeltaResponse, MailServerProfileCreateRequest, MailServerProfileListResponse, MailServerProfileResponse, MailServerProfileUpdateRequest, MailSmtpTestRequest, ) -from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope +from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.api.v1.schemas import DeltaDeletedItem +from govoplan_core.core.change_sequence import ( + ChangeSequenceEntry, + decode_sequence_watermark, + encode_sequence_watermark, + record_change, + sequence_watermark_is_expired, +) +from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint from govoplan_core.db.session import get_session from govoplan_mail.backend.mail_profiles import ( MailProfileError, @@ -39,10 +51,18 @@ from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.config import ImapConfig, SmtpConfig from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login from govoplan_mail.backend.sending.smtp import test_smtp_login -from sqlalchemy.orm import Session router = APIRouter(prefix="/mail", tags=["mail"]) +MAIL_MODULE_ID = "mail" +MAIL_PROFILES_COLLECTION = "mail.profiles" +MAIL_POLICIES_COLLECTION = "mail.profile_policies" +MAIL_SETTINGS_COLLECTIONS = (MAIL_PROFILES_COLLECTION, MAIL_POLICIES_COLLECTION) +MAIL_PROFILE_RESOURCE = "mail_profile" +MAIL_POLICY_RESOURCE = "mail_profile_policy" +MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1" +DEFAULT_MAILBOX_MESSAGE_LIMIT = 50 + def _require_scope(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): @@ -95,6 +115,122 @@ def _profile_response(profile) -> MailServerProfileResponse: return MailServerProfileResponse.model_validate(profile_response_payload(profile)) +def _policy_response( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, + campaign_id: str | None = None, +) -> MailProfilePolicyResponse: + policy = get_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id) + effective_policy = effective_mail_profile_policy_for_scope( + session, + tenant_id=tenant_id, + scope_type=scope_type, + scope_id=scope_id, + campaign_id=campaign_id, + ) + effective = effective_policy.as_dict() + effective_sources = effective_policy.source_policies + parent_policy = parent_mail_profile_policy(session, tenant_id=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, + ) + + +def _record_mail_change( + session: Session, + *, + collection: str, + resource_type: str, + resource_id: str | None, + operation: str, + principal: ApiPrincipal, + tenant_id: str | None, + payload: dict[str, object] | None = None, +) -> None: + if not resource_id: + return + record_change( + session, + module_id=MAIL_MODULE_ID, + collection=collection, + resource_type=resource_type, + resource_id=resource_id, + operation=operation, + tenant_id=tenant_id, + actor_type="user", + actor_id=principal.user.id, + payload=payload or {}, + ) + + +def _mail_delta_query(session: Session, *, tenant_id: str, since_sequence: int): + return session.query(ChangeSequenceEntry).filter( + ChangeSequenceEntry.id > since_sequence, + ChangeSequenceEntry.module_id == MAIL_MODULE_ID, + ChangeSequenceEntry.collection.in_(MAIL_SETTINGS_COLLECTIONS), + or_(ChangeSequenceEntry.tenant_id == tenant_id, ChangeSequenceEntry.tenant_id.is_(None)), + ) + + +def _mail_settings_watermark(session: Session, *, tenant_id: str) -> str: + sequence_id = _mail_delta_query(session, tenant_id=tenant_id, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar() + return encode_sequence_watermark(int(sequence_id or 0)) + + +def _mail_settings_entries(session: Session, *, tenant_id: str, since: str, limit: int): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + expired_for_tenant = sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=tenant_id, + module_id=MAIL_MODULE_ID, + collections=MAIL_SETTINGS_COLLECTIONS, + ) + expired_for_system = sequence_watermark_is_expired( + session, + since=since_sequence, + tenant_id=None, + module_id=MAIL_MODULE_ID, + collections=MAIL_SETTINGS_COLLECTIONS, + ) + if expired_for_tenant or expired_for_system: + return None, False + entries_plus_one = _mail_delta_query( + session, + tenant_id=tenant_id, + since_sequence=since_sequence, + ).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all() + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _mail_settings_response_watermark(session: Session, *, tenant_id: str, entries, has_more: bool) -> str: + return encode_sequence_watermark(entries[-1].id) if has_more and entries else _mail_settings_watermark(session, tenant_id=tenant_id) + + +def _mail_deleted_item(entry) -> DeltaDeletedItem: + return DeltaDeletedItem( + id=entry.resource_id, + resource_type=entry.resource_type, + revision=encode_sequence_watermark(entry.id), + deleted_at=entry.created_at if entry.operation == "deleted" else None, + ) + + def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse: return MailMailboxMessageSummaryResponse( uid=message.uid, @@ -132,6 +268,76 @@ def _mailbox_detail_response(message) -> MailMailboxMessageDetailResponse: ) +def _mailbox_cursor_fingerprint(*, tenant_id: str, profile_id: str, folder: str, limit: int) -> str: + return keyset_query_fingerprint( + MAILBOX_MESSAGES_CURSOR_SCOPE, + {"tenant_id": tenant_id, "profile_id": profile_id, "folder": folder, "limit": limit, "sort": "imap.uid.desc"}, + ) + + +def _mailbox_cursor_values(cursor: str | None, *, fingerprint: str | None = None) -> dict[str, object] | None: + try: + return decode_keyset_cursor(MAILBOX_MESSAGES_CURSOR_SCOPE, cursor, fingerprint=fingerprint) + except KeysetCursorError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +def _mailbox_cursor_limit(cursor: str | None, explicit_limit: int | None) -> int: + if explicit_limit is not None: + return explicit_limit + values = _mailbox_cursor_values(cursor) if cursor else None + raw_limit = values.get("limit") if values else DEFAULT_MAILBOX_MESSAGE_LIMIT + if not isinstance(raw_limit, int) or raw_limit < 1 or raw_limit > 100: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor") + return raw_limit + + +def _mailbox_cursor_position(cursor: str | None, *, fingerprint: str, offset: int) -> tuple[int, str | None, str | None, dict[str, object] | None]: + values = _mailbox_cursor_values(cursor, fingerprint=fingerprint) if cursor else None + if values is None: + return offset, None, None, None + raw_offset = values.get("offset") + if not isinstance(raw_offset, int) or raw_offset < 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor") + after_uid = values.get("after_uid") or values.get("anchor_uid") + uidvalidity = values.get("uidvalidity") + if not isinstance(after_uid, str) or not after_uid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor") + if uidvalidity is not None and not isinstance(uidvalidity, str): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor") + return raw_offset, after_uid, uidvalidity, values + + +def _next_mailbox_cursor( + *, + tenant_id: str, + profile_id: str, + folder: str, + limit: int, + offset: int, + total_count: int, + uidvalidity: str | None, + messages, +) -> tuple[str | None, bool]: + cursor_stable = bool(uidvalidity) + next_offset = offset + len(messages) + if not cursor_stable or next_offset >= total_count or not messages: + return None, cursor_stable + return ( + encode_keyset_cursor( + MAILBOX_MESSAGES_CURSOR_SCOPE, + fingerprint=_mailbox_cursor_fingerprint(tenant_id=tenant_id, profile_id=profile_id, folder=folder, limit=limit), + values={ + "offset": next_offset, + "limit": limit, + "uidvalidity": uidvalidity, + "after_uid": messages[-1].uid, + }, + ), + cursor_stable, + ) + + def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str): profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True) imap = imap_config_from_profile(profile) @@ -146,6 +352,104 @@ def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, sco return parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id).as_dict() +def _full_mail_settings_delta_response( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, + include_inactive: bool, + campaign_id: str | None, +) -> MailSettingsDeltaResponse: + profiles = list_mail_server_profiles( + session, + tenant_id=tenant_id, + include_inactive=include_inactive, + campaign_id=campaign_id, + ) + return MailSettingsDeltaResponse( + profiles=[_profile_response(profile) for profile in profiles], + policy=_policy_response(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id), + changed_sections=["profiles", "policy"], + deleted=[], + watermark=_mail_settings_watermark(session, tenant_id=tenant_id), + has_more=False, + full=True, + ) + + +@router.get("/settings/delta", response_model=MailSettingsDeltaResponse) +def mail_settings_delta( + scope_type: str = Query(default="tenant"), + scope_id: str | None = Query(default=None), + include_inactive: bool = False, + campaign_id: str | None = Query(default=None), + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + clean_scope_type = scope_type.strip().casefold() + _require_any_scope(principal, "mail:profile:read", "system:settings:read") + _require_policy_read_scope(principal, clean_scope_type) + if since is None: + return _full_mail_settings_delta_response( + session, + tenant_id=principal.tenant_id, + scope_type=clean_scope_type, + scope_id=scope_id, + include_inactive=include_inactive, + campaign_id=campaign_id, + ) + entries, has_more = _mail_settings_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit) + if entries is None: + return _full_mail_settings_delta_response( + session, + tenant_id=principal.tenant_id, + scope_type=clean_scope_type, + scope_id=scope_id, + include_inactive=include_inactive, + campaign_id=campaign_id, + ) + changed_profile_ids = { + entry.resource_id + for entry in entries + if entry.collection == MAIL_PROFILES_COLLECTION and entry.resource_type == MAIL_PROFILE_RESOURCE + } + visible_profiles = { + profile.id: profile + for profile in list_mail_server_profiles( + session, + tenant_id=principal.tenant_id, + include_inactive=include_inactive, + campaign_id=campaign_id, + ) + if profile.id in changed_profile_ids + } + policy_changed = any(entry.collection == MAIL_POLICIES_COLLECTION for entry in entries) + changed_sections = [] + if changed_profile_ids: + changed_sections.append("profiles") + if policy_changed: + changed_sections.append("policy") + deleted = [ + _mail_deleted_item(entry) + for entry in entries + if entry.collection == MAIL_PROFILES_COLLECTION + and entry.resource_type == MAIL_PROFILE_RESOURCE + and entry.resource_id not in visible_profiles + ] + return MailSettingsDeltaResponse( + profiles=[_profile_response(profile) for profile in visible_profiles.values()], + policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope_type, scope_id=scope_id, campaign_id=campaign_id) if policy_changed else None, + changed_sections=changed_sections, + deleted=deleted, + watermark=_mail_settings_response_watermark(session, tenant_id=principal.tenant_id, entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + @router.get("/profiles", response_model=MailServerProfileListResponse) def list_profiles( include_inactive: bool = False, @@ -189,12 +493,22 @@ def create_profile( scope_type=payload.scope_type, scope_id=payload.scope_id, ) + _record_mail_change( + session, + collection=MAIL_PROFILES_COLLECTION, + resource_type=MAIL_PROFILE_RESOURCE, + resource_id=profile.id, + operation="created", + principal=principal, + tenant_id=profile.tenant_id or principal.tenant_id, + payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id}, + ) session.commit() session.refresh(profile) return _profile_response(profile) except MailProfileError as exc: session.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse) @@ -245,12 +559,22 @@ def update_profile( imap=imap_config, clear_imap=payload.clear_imap, ) + _record_mail_change( + session, + collection=MAIL_PROFILES_COLLECTION, + resource_type=MAIL_PROFILE_RESOURCE, + resource_id=profile.id, + operation="updated", + principal=principal, + tenant_id=profile.tenant_id or principal.tenant_id, + payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id}, + ) session.commit() session.refresh(profile) return _profile_response(profile) except MailProfileError as exc: session.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse) @@ -264,6 +588,16 @@ def deactivate_profile( _require_profile_write_scope(principal, profile.scope_type or "tenant") profile.is_active = False session.add(profile) + _record_mail_change( + session, + collection=MAIL_PROFILES_COLLECTION, + resource_type=MAIL_PROFILE_RESOURCE, + resource_id=profile.id, + operation="deleted", + principal=principal, + tenant_id=profile.tenant_id or principal.tenant_id, + payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id}, + ) session.commit() session.refresh(profile) return _profile_response(profile) @@ -283,20 +617,7 @@ def read_mail_profile_policy( scope_type = scope_type.strip().casefold() _require_policy_read_scope(principal, scope_type) try: - policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) - effective_policy = effective_mail_profile_policy_for_scope( - session, - tenant_id=principal.tenant_id, - scope_type=scope_type, - scope_id=scope_id, - campaign_id=campaign_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) + return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -319,22 +640,21 @@ def write_mail_profile_policy( scope_id=scope_id, policy=payload.policy.model_dump(mode="json"), ) - session.commit() - effective_policy = effective_mail_profile_policy_for_scope( + _record_mail_change( session, - tenant_id=principal.tenant_id, - scope_type=scope_type, - scope_id=scope_id, + collection=MAIL_POLICIES_COLLECTION, + resource_type=MAIL_POLICY_RESOURCE, + resource_id=f"{scope_type}:{scope_id or ''}", + operation="updated", + principal=principal, + tenant_id=None if scope_type == "system" else principal.tenant_id, + payload={"scope_type": scope_type, "scope_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) + session.commit() + return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) except MailProfileError as exc: session.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse) @@ -414,7 +734,7 @@ def list_profile_mailbox_folders( except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except ImapConfigurationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc except ImapAppendError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc @@ -423,15 +743,37 @@ def list_profile_mailbox_folders( def list_profile_mailbox_messages( profile_id: str, folder: str = Query(default="INBOX", min_length=1, max_length=255), - limit: int = Query(default=50, ge=1, le=100), + limit: int | None = Query(default=None, ge=1, le=100), offset: int = Query(default=0, ge=0, le=100000), + cursor: str | None = None, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_mailbox_read_scope(principal) try: imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) - result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset) + effective_limit = _mailbox_cursor_limit(cursor, limit) + fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit) + effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset) + result = list_imap_messages( + imap_config=imap, + folder=folder, + limit=effective_limit, + offset=effective_offset, + after_uid=after_uid, + expected_uidvalidity=expected_uidvalidity, + ) + full = bool(cursor_values is not None and result.cursor_reset) + next_cursor, cursor_stable = _next_mailbox_cursor( + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=result.folder, + limit=result.limit, + offset=result.offset, + total_count=result.total_count, + uidvalidity=result.uidvalidity, + messages=result.messages, + ) return MailMailboxMessageListResponse( profile_id=profile_id, folder=result.folder, @@ -441,12 +783,16 @@ def list_profile_mailbox_messages( total_count=result.total_count, offset=result.offset, limit=result.limit, + cursor=cursor, + next_cursor=next_cursor, + cursor_stable=cursor_stable, + full=full or not cursor_stable, messages=[_mailbox_summary_response(message) for message in result.messages], ) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except ImapConfigurationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc except ImapAppendError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc @@ -474,7 +820,7 @@ def get_profile_mailbox_message( except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except ImapConfigurationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc except ImapAppendError as exc: status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).casefold() else status.HTTP_502_BAD_GATEWAY raise HTTPException(status_code=status_code, detail=str(exc)) from exc diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index 03aa125..bc9ee6c 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -5,6 +5,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator +from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials @@ -93,6 +94,7 @@ class MailProfilePolicyUpdateRequest(BaseModel): class PolicySourceStepResponse(BaseModel): scope_type: str scope_id: str | None = None + path: str label: str applied_fields: list[str] = Field(default_factory=list) policy: dict[str, Any] = Field(default_factory=dict) @@ -201,6 +203,16 @@ class MailServerProfileListResponse(BaseModel): profiles: list[MailServerProfileResponse] = Field(default_factory=list) +class MailSettingsDeltaResponse(BaseModel): + profiles: list[MailServerProfileResponse] = Field(default_factory=list) + policy: MailProfilePolicyResponse | None = None + changed_sections: list[str] = Field(default_factory=list) + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class MailConnectionTestResponse(BaseModel): ok: bool protocol: Literal["smtp", "imap"] @@ -266,6 +278,10 @@ class MailMailboxMessageListResponse(BaseModel): total_count: int = 0 offset: int = 0 limit: int = 50 + cursor: str | None = None + next_cursor: str | None = None + cursor_stable: bool = False + full: bool = False messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) @@ -276,4 +292,3 @@ class MailMailboxMessageResponse(BaseModel): port: int | None = None security: str | None = None message: MailMailboxMessageDetailResponse - diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py index 42ea420..9246bf4 100644 --- a/src/govoplan_mail/backend/sending/imap.py +++ b/src/govoplan_mail/backend/sending/imap.py @@ -115,6 +115,8 @@ class ImapMailboxMessageListResult: total_count: int offset: int limit: int + uidvalidity: str | None = None + cursor_reset: bool = False @dataclass(frozen=True, slots=True) @@ -527,15 +529,23 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) - return parts -def _select_readonly(client: imaplib.IMAP4, folder: str) -> int: +def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]: typ, data = client.select(folder, readonly=True) if typ != "OK": raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) selected_count = _decode_item(data[0] if data else None).strip() try: - return max(0, int(selected_count)) + total_count = max(0, int(selected_count)) except ValueError: - return 0 + total_count = 0 + uidvalidity = None + try: + _, uidvalidity_data = client.response("UIDVALIDITY") + if uidvalidity_data: + uidvalidity = _decode_item(uidvalidity_data[0]).strip() or None + except Exception: + uidvalidity = None + return total_count, uidvalidity def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]: @@ -590,6 +600,63 @@ def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[ return summaries +def _search_message_uids(client: imaplib.IMAP4) -> list[str]: + typ, data = client.uid("search", None, "ALL") + if typ != "OK": + raise ImapAppendError(f"IMAP UID search failed: {data!r}", temporary=True) + uids: set[int] = set() + for item in data or []: + for token in _decode_item(item).split(): + if token.isdigit(): + uids.add(int(token)) + return [str(uid) for uid in sorted(uids, reverse=True)] + + +def _paged_descending_uids( + uids: list[str], + *, + offset: int, + limit: int, + after_uid: str | None = None, +) -> tuple[list[str], int, bool]: + if not uids: + return [], 0, False + cursor_reset = False + if after_uid: + try: + start = uids.index(str(after_uid)) + 1 + except ValueError: + start = 0 + cursor_reset = True + else: + start = min(max(0, offset), len(uids)) + return uids[start:start + limit], start, cursor_reset + + +def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]: + if not uids: + return [] + typ, data = client.uid("fetch", _sequence_set(uids), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])") + if typ != "OK": + raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True) + + by_uid: dict[str, ImapMailboxMessageSummary] = {} + for _sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data): + if not fetched_uid: + continue + by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes) + + summaries: list[ImapMailboxMessageSummary] = [] + for uid in uids: + summary = by_uid.get(str(uid)) + if summary is not None: + summaries.append(summary) + continue + fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid) + summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)) + return summaries + + def _mock_record_folder(record: dict[str, Any]) -> str: folder = str(record.get("folder") or "").strip() if folder: @@ -612,7 +679,15 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes: return "\n".join(lines).encode("utf-8", errors="replace") -def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult: +def list_imap_messages( + *, + imap_config: ImapConfig, + folder: str = "INBOX", + limit: int = 50, + offset: int = 0, + after_uid: str | None = None, + expected_uidvalidity: str | None = None, +) -> ImapMailboxMessageListResult: """List mailbox messages without mutating read/unread state.""" host, port = _require_imap_config(imap_config) @@ -621,7 +696,20 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: offset = max(0, offset) if is_mock_imap_host(imap_config.host): records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] - page_records = records[offset:offset + limit] + cursor_reset = False + if expected_uidvalidity and expected_uidvalidity != "mock-v1": + effective_offset = 0 + cursor_reset = True + elif after_uid: + record_ids = [str(record.get("id") or "") for record in records] + try: + effective_offset = record_ids.index(str(after_uid)) + 1 + except ValueError: + effective_offset = 0 + cursor_reset = True + else: + effective_offset = min(offset, len(records)) + page_records = records[effective_offset:effective_offset + limit] messages = [ _message_summary_from_raw( uid=str(record.get("id") or ""), @@ -632,14 +720,49 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: ) for record in page_records ] - return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit) + return ImapMailboxMessageListResult( + host=host, + port=port, + security=imap_config.security.value, + folder=folder, + messages=messages, + total_count=len(records), + offset=effective_offset, + limit=limit, + uidvalidity="mock-v1", + cursor_reset=cursor_reset, + ) client = _open_imap(imap_config) try: - total_count = _select_readonly(client, folder) - page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit) - messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder) - return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit) + total_count, uidvalidity = _select_readonly(client, folder) + uids = _search_message_uids(client) + cursor_reset = False + effective_after_uid = after_uid + effective_offset = offset + if expected_uidvalidity and expected_uidvalidity != uidvalidity: + effective_after_uid = None + effective_offset = 0 + cursor_reset = True + page_uids, effective_offset, anchor_missing = _paged_descending_uids( + uids, + offset=effective_offset, + limit=limit, + after_uid=effective_after_uid, + ) + messages = _fetch_message_summaries_by_uid(client, page_uids, folder) + return ImapMailboxMessageListResult( + host=host, + port=port, + security=imap_config.security.value, + folder=folder, + messages=messages, + total_count=len(uids) if uids else total_count, + offset=effective_offset, + limit=limit, + uidvalidity=uidvalidity, + cursor_reset=cursor_reset or anchor_missing, + ) finally: try: client.logout() diff --git a/webui/package.json b/webui/package.json index df3d262..c4af5ff 100644 --- a/webui/package.json +++ b/webui/package.json @@ -15,7 +15,7 @@ }, "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 4a9af93..da0494a 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -1,4 +1,4 @@ -import type { ApiSettings } from "../types"; +import type { ApiSettings, DeltaDeletedItem } from "../types"; import { apiFetch } from "./client"; export type MailSecurity = "plain" | "tls" | "starttls"; @@ -95,6 +95,10 @@ export type MailMailboxMessageListResponse = { total_count?: number; offset?: number; limit?: number; + cursor?: string | null; + next_cursor?: string | null; + cursor_stable?: boolean; + full?: boolean; messages: MailMailboxMessageSummary[]; }; @@ -127,6 +131,16 @@ export type MailServerProfile = { export type MailServerProfileListResponse = { profiles: MailServerProfile[] }; +export type MailSettingsDeltaResponse = { + profiles: MailServerProfile[]; + policy?: MailProfilePolicyResponse | null; + changed_sections?: string[]; + deleted: DeltaDeletedItem[]; + watermark?: string | null; + has_more: boolean; + full: boolean; +}; + export const mailProfilePatternKeys = [ "smtp_hosts", "imap_hosts", @@ -215,6 +229,28 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact return response.profiles ?? []; } +export async function fetchMailSettingsDelta( + settings: ApiSettings, + params: { + scope_type?: MailProfileScope; + scope_id?: string | null; + include_inactive?: boolean; + campaign_id?: string | null; + since?: string | null; + limit?: number; + } = {} +): Promise { + const search = new URLSearchParams(); + if (params.scope_type) search.set("scope_type", params.scope_type); + if (params.scope_id) search.set("scope_id", params.scope_id); + if (params.include_inactive) search.set("include_inactive", "true"); + if (params.campaign_id) search.set("campaign_id", params.campaign_id); + if (params.since) search.set("since", params.since); + if (params.limit) search.set("limit", String(params.limit)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/mail/settings/delta${suffix}`); +} + export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise { return apiFetch(settings, "/api/v1/mail/profiles", { method: "POST", @@ -284,9 +320,11 @@ export async function listMailboxMessages( profileId: string, folder = "INBOX", limit = 50, - offset = 0 + offset = 0, + cursor?: string | null ): Promise { const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) }); + if (cursor) params.set("cursor", cursor); return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`); } diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index db6833f..1cbf19c 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -1,14 +1,14 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { FieldLabel, MailServerSettingsPanel, PolicyLockedHint, PolicySourcePath, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailServerSecurity, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, type PolicySourcePathItem } from "@govoplan/core-webui"; +import { ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyRow, PolicySourcePath, PolicyTable, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, type PolicySourcePathItem } from "@govoplan/core-webui"; import { Pencil, Plus, Trash2 } from "lucide-react"; import type { ApiSettings } from "../../types"; import { createMailServerProfile, deactivateMailServerProfile, + fetchMailSettingsDelta, getMailProfilePolicy, listImapFolders, listMailProfileImapFolders, - listMailServerProfiles, mailProfilePatternKeys, mailProfilePolicyLimitKeys, updateMailProfilePolicy, @@ -28,16 +28,15 @@ import { type MailServerProfile, type MailServerProfilePayload, type MailServerProfileUpdatePayload, - type MailSmtpTestPayload -} from "../../api/mail"; + type MailSmtpTestPayload } from +"../../api/mail"; import { validateMailPolicy } from "./mailPolicyValidation"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; -import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; -import { FormField } from "@govoplan/core-webui"; +import { FormField, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; export type MailProfileTargetOption = { id: string; label: string; @@ -95,15 +94,18 @@ type MailProfilePolicyEditorProps = { type EditingProfile = MailServerProfile | "new" | null; type PolicyFlagValue = "inherit" | "allow" | "deny"; type CredentialInheritanceValue = "inherit" | "profile" | "local"; +type MailProfileTreeRow = +{kind: "profile";id: string;profile: MailServerProfile;} | +{kind: "credential";id: string;profile: MailServerProfile;protocol: "smtp" | "imap";}; const securityOptions = mailServerSecurityOptions as readonly MailSecurity[]; const patternLabels: Record = { - smtp_hosts: "SMTP hostnames", - imap_hosts: "IMAP hostnames", - envelope_senders: "Envelope senders", - from_headers: "From headers", - recipient_domains: "Recipient domains" + smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8", + imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78", + envelope_senders: "i18n:govoplan-mail.envelope_senders.269065cd", + from_headers: "i18n:govoplan-mail.from_headers.b3ea473b", + recipient_domains: "i18n:govoplan-mail.recipient_domains.cb9b7b44" }; const blankPolicy: MailProfilePolicy = { @@ -123,9 +125,9 @@ export function MailProfileScopeManager({ scopeType, scopeId = null, targetOptions = [], - targetLabel = "Target", - profileTitle = "Mail server profiles", - policyTitle = "Mail profile policy", + targetLabel = "i18n:govoplan-mail.target.61ad50a9", + profileTitle = "i18n:govoplan-mail.mail_server_profiles.b1726682", + policyTitle = "i18n:govoplan-mail.mail_profile_policy.f2ac4b92", canWriteProfiles, canManageCredentials, canWritePolicy @@ -135,18 +137,28 @@ export function MailProfileScopeManager({ const [editing, setEditing] = useState(null); const [deactivating, setDeactivating] = useState(null); const [draft, setDraft] = useState(emptyProfileDraft()); + const [savedProfileDraftKey, setSavedProfileDraftKey] = useState(profileDraftKey(emptyProfileDraft())); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [profileEffectivePolicy, setProfileEffectivePolicy] = useState(null); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign"; const targetSelectionRequired = requiresTarget && !scopeId; const hasSelectableTarget = targetOptions.length > 0; const activeScopeId = scopeId || selectedTargetId || null; + const deltaKey = `mail:settings:${scopeType}:${activeScopeId ?? ""}`; const scopeReady = !requiresTarget || Boolean(activeScopeId); - const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`; + const targetEmptyText = i18nMessage("i18n:govoplan-mail.no_value_available", { value0: targetPluralLabel(scopeType, targetLabel) }); + const profileDirty = editing !== null && profileDraftKey(draft) !== savedProfileDraftKey; + + useUnsavedDraftGuard({ + dirty: profileDirty, + onSave: saveProfile, + onDiscard: closeProfileEditor + }); useEffect(() => { if (scopeId) { @@ -160,27 +172,52 @@ export function MailProfileScopeManager({ if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId(""); }, [scopeId, selectedTargetId, targetOptions]); - useEffect(() => { void loadProfiles(); }, [activeScopeId, scopeReady, scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + useEffect(() => { + resetDeltaWatermark(deltaKey); + void loadProfiles(true); + }, [deltaKey, scopeReady, settings.accessToken, settings.apiBaseUrl, settings.apiKey, resetDeltaWatermark]); - async function loadProfiles() { + async function loadProfiles(forceFull = false) { setLoading(true); setError(""); if (!scopeReady) { setProfiles([]); setProfileEffectivePolicy(null); + resetDeltaWatermark(deltaKey); setLoading(false); return; } try { - const [nextProfiles, policyResponse] = await Promise.all([ - listMailServerProfiles(settings, true), - getMailProfilePolicy(settings, scopeType, activeScopeId) - ]); + let nextProfiles = forceFull ? [] : profiles; + let nextEffectivePolicy = forceFull ? null : profileEffectivePolicy; + let nextWatermark = forceFull ? null : getDeltaWatermark(deltaKey); + let hasMore = false; + do { + const response = await fetchMailSettingsDelta(settings, { + scope_type: scopeType, + scope_id: activeScopeId, + include_inactive: true, + since: nextWatermark + }); + nextProfiles = response.full ? + response.profiles : + mergeDeltaRows(nextProfiles, response.profiles, response.deleted, (profile) => profile.id, { + deletedResourceType: "mail_profile", + sort: sortMailProfiles + }); + if (response.policy) { + nextEffectivePolicy = response.policy.effective_policy ?? null; + } + nextWatermark = response.watermark ?? null; + hasMore = response.has_more; + } while (hasMore); setProfiles(nextProfiles); - setProfileEffectivePolicy(policyResponse?.effective_policy ?? null); + setProfileEffectivePolicy(nextEffectivePolicy); + setDeltaWatermark(deltaKey, nextWatermark); } catch (err) { setProfiles([]); setProfileEffectivePolicy(null); + resetDeltaWatermark(deltaKey); setError(errorMessage(err)); } finally { setLoading(false); @@ -193,21 +230,32 @@ export function MailProfileScopeManager({ ); function openCreate() { - setDraft(emptyProfileDraft()); + const nextDraft = emptyProfileDraft(); + setDraft(nextDraft); + setSavedProfileDraftKey(profileDraftKey(nextDraft)); setEditing("new"); setError(""); setSuccess(""); } function openEdit(profile: MailServerProfile) { - setDraft(profileToDraft(profile)); + const nextDraft = profileToDraft(profile); + setDraft(nextDraft); + setSavedProfileDraftKey(profileDraftKey(nextDraft)); setEditing(profile); setError(""); setSuccess(""); } - async function saveProfile() { - if (!editing || !scopeReady) return; + function closeProfileEditor() { + setEditing(null); + const nextDraft = emptyProfileDraft(); + setDraft(nextDraft); + setSavedProfileDraftKey(profileDraftKey(nextDraft)); + } + + async function saveProfile(): Promise { + if (!editing || !scopeReady) return false; setBusy(true); setError(""); setSuccess(""); @@ -215,16 +263,18 @@ export function MailProfileScopeManager({ if (editing === "new") { const payload = createProfilePayload(draft, scopeType, activeScopeId); const created = await createMailServerProfile(settings, payload); - setSuccess(`Profile ${created.name} created.`); + setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name })); } else { const payload = updateProfilePayload(draft, editing); const updated = await updateMailServerProfile(settings, editing.id, payload); - setSuccess(`Profile ${updated.name} updated.`); + setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name })); } setEditing(null); await loadProfiles(); + return true; } catch (err) { setError(errorMessage(err)); + return false; } finally { setBusy(false); } @@ -237,7 +287,7 @@ export function MailProfileScopeManager({ setSuccess(""); try { await deactivateMailServerProfile(settings, deactivating.id); - setSuccess(`Profile ${deactivating.name} deactivated.`); + setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a", { value0: deactivating.name })); setDeactivating(null); await loadProfiles(); } catch (err) { @@ -247,94 +297,141 @@ export function MailProfileScopeManager({ } } - const columns = useMemo[]>(() => [ - { id: "name", header: "Profile", width: "minmax(220px, 1fr)", sticky: "start", resizable: true, sortable: true, filterable: true, value: (profile) => `${profile.name} ${profile.slug}`, render: (profile) =>
{profile.name}
{profile.slug}
}, - { id: "scope", header: "Scope", width: 120, resizable: true, sortable: true, filterable: true, value: (profile) => scopeLabel(profile) }, - { id: "smtp", header: "SMTP", width: 220, sortable: true, filterable: true, value: (profile) => transportLabel(profile.smtp), render: (profile) => }, - { id: "imap", header: "IMAP", width: 220, sortable: true, filterable: true, value: (profile) => profile.imap ? transportLabel(profile.imap) : "Not configured", render: (profile) => }, - { id: "status", header: "Status", width: 110, sortable: true, filterable: true, value: (profile) => profile.is_active ? "Active" : "Inactive", render: (profile) => {profile.is_active ? "Active" : "Inactive"} }, - { id: "actions", header: "Actions", width: 145, sticky: "end", align: "right", render: (profile) =>
- - -
} - ], [busy, canWriteProfiles]); + const treeRows = useMemo( + () => scopedProfiles.map((profile) => ({ kind: "profile", id: `profile:${profile.id}`, profile })), + [scopedProfiles] + ); + const columns = useMemo[]>(() => [ + { + id: "profile", + header: "i18n:govoplan-mail.server_credential.7fb9a24e", + width: "minmax(260px, 1.4fr)", + render: (row) => row.kind === "profile" ? +
{row.profile.name}
{row.profile.slug} · {scopeLabel(row.profile)}
: + + }, + { + id: "transport", + header: "i18n:govoplan-mail.transport.c10d76c9", + width: "minmax(220px, 1fr)", + render: (row) => row.kind === "profile" ? + {transportLabel(row.profile.smtp)}{row.profile.imap ? i18nMessage("i18n:govoplan-mail.value.48afe802", { value0: transportLabel(row.profile.imap) }) : ""} : + + }, + { + id: "policy", + header: "i18n:govoplan-mail.policy.bb9cf141", + width: "minmax(150px, 0.8fr)", + render: (row) => row.kind === "profile" ? scopeLabel(row.profile) : mailCredentialPolicySummary(profileEffectivePolicy, row.protocol) + }, + { + id: "status", + header: "i18n:govoplan-mail.status.bae7d5be", + width: "110px", + render: (row) => row.kind === "profile" ? + {row.profile.is_active ? "i18n:govoplan-mail.active.a733b809" : "i18n:govoplan-mail.inactive.09af574c"} : + {mailCredentialConfigured(row.profile, row.protocol) ? "i18n:govoplan-mail.saved.c0ae8f6e" : "i18n:govoplan-mail.local.dc99d54d"} + }], + [profileEffectivePolicy]); + + function mailProfileChildren(row: MailProfileTreeRow): MailProfileTreeRow[] { + if (row.kind !== "profile") return []; + const children: MailProfileTreeRow[] = [{ kind: "credential", id: `credential:${row.profile.id}:smtp`, profile: row.profile, protocol: "smtp" }]; + if (row.profile.imap) children.push({ kind: "credential", id: `credential:${row.profile.id}:imap`, profile: row.profile, protocol: "imap" }); + return children; + } + + function renderMailProfileActions(row: MailProfileTreeRow) { + if (row.kind === "credential") { + return ( +
+ +
); + + } + return ( +
+ + +
); + + } return (
- {targetSelectionRequired && ( - + {targetSelectionRequired && +
- )} - - {scopeReady && ( - <> - + } + {scopeReady && + <> - - + title={profileTitle} + actions={ +
+ +
- } - > -
- profile.id} - emptyText="No profiles in this scope." - /> -
+ }> + + + row.id} + getChildren={mailProfileChildren} + renderActions={renderMailProfileActions} + emptyText="i18n:govoplan-mail.no_profiles_in_this_scope.302b21d8" /> + +
+ + - )} + } {error && {error}} {success && {success}} !busy && setEditing(null)} className="admin-dialog admin-dialog-wide mail-profile-dialog" closeDisabled={busy} - footer={<>} - > + footer={<>}> + setDeactivating(null)} - onConfirm={() => void deactivateProfile()} - /> -
- ); + onConfirm={() => void deactivateProfile()} /> + + ); + } export function MailProfilePolicyEditor({ @@ -347,14 +444,15 @@ export function MailProfilePolicyEditor({ ownerGroupId = null, canWrite, locked = false, - title = "Mail profile policy", - description = "Allowed profiles and wildcard rules for this scope.", + title = "i18n:govoplan-mail.mail_profile_policy.f2ac4b92", + description = "i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4", onSaved }: MailProfilePolicyEditorProps) { const [policy, setPolicy] = useState(blankPolicy); const [effectivePolicy, setEffectivePolicy] = useState(null); const [parentPolicy, setParentPolicy] = useState(null); const [effectivePolicySources, setEffectivePolicySources] = useState([]); + const [savedPolicyKey, setSavedPolicyKey] = useState(policyDraftKey(blankPolicy)); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -363,14 +461,22 @@ export function MailProfilePolicyEditor({ const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign"; const scopeReady = !requiresTarget || Boolean(scopeId); + const policyDirty = scopeReady && policyDraftKey(policy) !== savedPolicyKey; - useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]); + useUnsavedDraftGuard({ + dirty: policyDirty, + onSave: savePolicy, + onDiscard: () => setPolicy(JSON.parse(savedPolicyKey) as MailProfilePolicy) + }); + + useEffect(() => {void loadPolicy();}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]); async function loadPolicy() { setError(""); setSuccess(""); if (!scopeReady) { setPolicy(blankPolicy); + setSavedPolicyKey(policyDraftKey(blankPolicy)); setEffectivePolicy(null); setParentPolicy(null); setEffectivePolicySources([]); @@ -379,12 +485,15 @@ export function MailProfilePolicyEditor({ setLoading(true); try { const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId); - setPolicy(normalizePolicy(response.policy)); + const loadedPolicy = normalizePolicy(response.policy); + setPolicy(loadedPolicy); + setSavedPolicyKey(policyDraftKey(loadedPolicy)); setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null); setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null); setEffectivePolicySources(response.effective_policy_sources ?? []); } catch (err) { setPolicy(blankPolicy); + setSavedPolicyKey(policyDraftKey(blankPolicy)); setEffectivePolicy(null); setParentPolicy(null); setEffectivePolicySources([]); @@ -394,21 +503,25 @@ export function MailProfilePolicyEditor({ } } - async function savePolicy() { - if (!scopeReady) return; + async function savePolicy(): Promise { + if (!scopeReady) return false; setBusy(true); setError(""); setSuccess(""); try { const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicyForSave(policy, parentPolicy, scopeType), scopeId); - setPolicy(normalizePolicy(response.policy)); + const savedPolicy = normalizePolicy(response.policy); + setPolicy(savedPolicy); + setSavedPolicyKey(policyDraftKey(savedPolicy)); setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null); setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null); setEffectivePolicySources(response.effective_policy_sources ?? []); - setSuccess("Mail profile policy saved."); + setSuccess("i18n:govoplan-mail.mail_profile_policy_saved.666847bf"); await onSaved?.(); + return true; } catch (err) { setError(errorMessage(err)); + return false; } finally { setBusy(false); } @@ -432,11 +545,11 @@ export function MailProfilePolicyEditor({ const showEffectiveColumn = !isSystem; const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids"); const blockedProfileDefinitions = [ - parentBlocksUserProfiles ? "user" : "", - parentBlocksGroupProfiles ? "group" : "", - parentBlocksCampaignProfiles ? "campaign-local settings" : "" - ].filter(Boolean).join(", "); - const lockedCredentialKinds = [smtpCredentialLocked ? "SMTP" : "", imapCredentialLocked ? "IMAP" : ""].filter(Boolean).join(" and "); + parentBlocksUserProfiles ? "user" : "", + parentBlocksGroupProfiles ? "group" : "", + parentBlocksCampaignProfiles ? "i18n:govoplan-mail.campaign_local_settings.920ecb62" : ""]. + filter(Boolean).join(", "); + const lockedCredentialKinds = [smtpCredentialLocked ? "i18n:govoplan-mail.smtp.efff9cca" : "", imapCredentialLocked ? "i18n:govoplan-mail.imap.271f9ef2" : ""].filter(Boolean).join(" and "); const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType); function patchPolicy(patch: Partial) { @@ -445,8 +558,8 @@ export function MailProfilePolicyEditor({ function toggleAllowedProfile(profileId: string, checked: boolean) { const next = new Set(policy.allowed_profile_ids ?? []); - if (checked) next.add(profileId); - else next.delete(profileId); + if (checked) next.add(profileId);else + next.delete(profileId); patchPolicy({ allowed_profile_ids: [...next].sort() }); } @@ -462,8 +575,8 @@ export function MailProfilePolicyEditor({ function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) { const nextRules = { ...(policy[kind] ?? {}) }; const parsed = parsePatternList(text); - if (parsed.length > 0) nextRules[key] = parsed; - else delete nextRules[key]; + if (parsed.length > 0) nextRules[key] = parsed;else + delete nextRules[key]; patchPolicy({ [kind]: nextRules }); } @@ -481,7 +594,7 @@ export function MailProfilePolicyEditor({ patchPolicy({ allow_lower_level_limits: { ...(policy.allow_lower_level_limits ?? {}), [key]: allowed } }); } - function lowerLevelLimitToggle(key: MailProfilePolicyLimitKey, label: ReactNode = "Allow override"): ReactNode | undefined { + function lowerLevelLimitToggle(key: MailProfilePolicyLimitKey, label: ReactNode = "i18n:govoplan-mail.allow_override.ffa6e9a0"): ReactNode | undefined { if (!showAllowColumn) return undefined; const parentLocked = !parentAllowsMailLimit(key); return ( @@ -489,138 +602,165 @@ export function MailProfilePolicyEditor({ checked={localAllowsMailLimit(key)} disabled={disabled || parentLocked} onChange={(checked) => setAllowLowerLevelLimit(key, checked)} - label={label} - /> - ); + label={label} />); + + } return ( - - +
+ +
- } - > + }> + +
{description &&

{description}

} {error && {error}} {success && {success}} -
+
-

Profile allow-list

+

i18n:govoplan-mail.profile_allow_list.507dfe6c

{lowerLevelLimitToggle("allowed_profile_ids")} - +
-

{selectedProfileIds.size === 0 ? "No local profile allow-list is set." : `${selectedProfileIds.size} profile(s) allowed by this scope.`}

+

{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}

- {candidateProfiles.map((profile) => ( -
- {parentAllowedProfileIds &&

An ancestor allow-list limits selectable profiles to {parentAllowedProfileIds.size} profile(s).

} + {parentAllowedProfileIds &&

i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179 {parentAllowedProfileIds.size} i18n:govoplan-mail.profile_s.742e9200

}
-
-

Lower-level mail definitions

- - +

i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d

+ + setFlag("allow_user_profiles", value)} />} effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_user_profiles, effectivePolicy) : undefined} - allowControl={lowerLevelLimitToggle("allow_user_profiles")} - effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_user_profiles", effectivePolicyPath) : undefined} - /> - {lowerLevelLimitToggle("allow_user_profiles")}
: undefined} + effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_user_profiles", effectivePolicyPath) : undefined} /> + + setFlag("allow_group_profiles", value)} />} effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_group_profiles, effectivePolicy) : undefined} - allowControl={lowerLevelLimitToggle("allow_group_profiles")} - effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_group_profiles", effectivePolicyPath) : undefined} - /> - {lowerLevelLimitToggle("allow_group_profiles")} : undefined} + effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_group_profiles", effectivePolicyPath) : undefined} /> + + setFlag("allow_campaign_profiles", value)} />} effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_campaign_profiles, effectivePolicy) : undefined} - allowControl={lowerLevelLimitToggle("allow_campaign_profiles")} - effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_campaign_profiles", effectivePolicyPath) : undefined} - /> - - {blockedProfileDefinitions && Explicit allow is unavailable for {blockedProfileDefinitions} because an ancestor policy blocks those definitions.} + allowControl={showAllowColumn ?
{lowerLevelLimitToggle("allow_campaign_profiles")}
: undefined} + effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_campaign_profiles", effectivePolicyPath) : undefined} /> + + + {blockedProfileDefinitions && i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a {blockedProfileDefinitions} i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d} -
-

Credential inheritance

- - +

i18n:govoplan-mail.credential_inheritance.ec8d7191

+ + setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />} - effective={showEffectiveColumn ? (effectivePolicy ? credentialInheritanceLabel(effectivePolicy.smtp_credentials) : "Loading...") : undefined} - allowControl={lowerLevelLimitToggle("smtp_credentials.inherit")} - effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("smtp_credentials", effectivePolicyPath) : undefined} - /> - {lowerLevelLimitToggle("smtp_credentials.inherit")} : undefined} + effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("smtp_credentials", effectivePolicyPath) : undefined} /> + + setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />} - effective={showEffectiveColumn ? (effectivePolicy ? credentialInheritanceLabel(effectivePolicy.imap_credentials) : "Loading...") : undefined} - allowControl={lowerLevelLimitToggle("imap_credentials.inherit")} - effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("imap_credentials", effectivePolicyPath) : undefined} - /> -
- {lockedCredentialKinds && {lockedCredentialKinds} credential inheritance is locked by an ancestor policy.} + effective={showEffectiveColumn ? effectivePolicy ? credentialInheritanceLabel(effectivePolicy.imap_credentials) : "i18n:govoplan-mail.loading.b04ba49f" : undefined} + allowControl={showAllowColumn ?
{lowerLevelLimitToggle("imap_credentials.inherit")}
: undefined} + effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("imap_credentials", effectivePolicyPath) : undefined} /> + + + {lockedCredentialKinds && {lockedCredentialKinds} i18n:govoplan-mail.credential_inheritance_is_locked_by_an_ancestor_.1ee5d263}
-
-

Wildcard rules

+
+

i18n:govoplan-mail.wildcard_rules.54fb3fc0

- Policy target - Whitelist - Blacklist - {showAllowColumn && Lower levels} + i18n:govoplan-mail.policy_target.a19dcee9 + i18n:govoplan-mail.whitelist.53c2ad30 + i18n:govoplan-mail.blacklist.7b2dd04c + {showAllowColumn && i18n:govoplan-mail.lower_levels.940821ee}
- {mailProfilePatternKeys.map((key) => ( -
+ {mailProfilePatternKeys.map((key) => +
{patternLabels[key]}
setPattern("whitelist", key, text)} /> setPattern("blacklist", key, text)} /> - {showAllowColumn && ( -
- {lowerLevelLimitToggle(`whitelist.${key}` as MailProfilePolicyLimitKey, "Whitelist")} - {lowerLevelLimitToggle(`blacklist.${key}` as MailProfilePolicyLimitKey, "Blacklist")} + {showAllowColumn && +
+ {lowerLevelLimitToggle(i18nMessage("i18n:govoplan-mail.whitelist_value.d4cc3755", { value0: key }) as MailProfilePolicyLimitKey, "i18n:govoplan-mail.whitelist.53c2ad30")} + {lowerLevelLimitToggle(i18nMessage("i18n:govoplan-mail.blacklist_value.556334d0", { value0: key }) as MailProfilePolicyLimitKey, "i18n:govoplan-mail.blacklist.7b2dd04c")}
- )} + }
- ))} + )}
- {showEffectiveColumn && effectivePolicy && ( -
-

Policy path

+ {showEffectiveColumn && effectivePolicy && +
+

i18n:govoplan-mail.policy_path.1ba91ee5

-

Effective values are shown in the table rows above.

+

i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d

- )} + } - - ); + + ); + } function ProfileForm({ @@ -632,16 +772,16 @@ function ProfileForm({ canWrite, canManageCredentials, effectivePolicy -}: { - settings: ApiSettings; - draft: ProfileDraft; - setDraft: (draft: ProfileDraft) => void; - editing: EditingProfile; - busy: boolean; - canWrite: boolean; - canManageCredentials: boolean; - effectivePolicy?: MailProfilePolicy | null; -}) { + + + + + + + + + +}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;}) { const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [folderResult, setFolderResult] = useState(null); @@ -708,9 +848,9 @@ function ProfileForm({ setMailActionState("smtp"); setSmtpTestResult(null); try { - setSmtpTestResult(useSavedSmtpTest && existingProfile - ? await testMailProfileSmtp(settings, existingProfile.id) - : await testSmtpSettings(settings, rawSmtpPayload(draft, false))); + setSmtpTestResult(useSavedSmtpTest && existingProfile ? + await testMailProfileSmtp(settings, existingProfile.id) : + await testSmtpSettings(settings, rawSmtpPayload(draft, false))); } catch (err) { setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} }); } finally { @@ -723,9 +863,9 @@ function ProfileForm({ setMailActionState("imap"); setImapTestResult(null); try { - setImapTestResult(useSavedImapTest && existingProfile - ? await testMailProfileImap(settings, existingProfile.id) - : await testImapSettings(settings, rawImapPayload(draft, false))); + setImapTestResult(useSavedImapTest && existingProfile ? + await testMailProfileImap(settings, existingProfile.id) : + await testImapSettings(settings, rawImapPayload(draft, false))); } catch (err) { setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} }); } finally { @@ -738,9 +878,9 @@ function ProfileForm({ setMailActionState("folders"); setFolderResult(null); try { - setFolderResult(useSavedImapTest && existingProfile - ? await listMailProfileImapFolders(settings, existingProfile.id) - : await listImapFolders(settings, rawImapPayload(draft, false))); + setFolderResult(useSavedImapTest && existingProfile ? + await listMailProfileImapFolders(settings, existingProfile.id) : + await listImapFolders(settings, rawImapPayload(draft, false))); } catch (err) { setFolderResult({ ok: false, message: errorMessage(err), folders: [] }); } finally { @@ -757,18 +897,18 @@ function ProfileForm({ return (
- setDraft({ ...draft, name: event.target.value })} /> - setDraft({ ...draft, slug: event.target.value })} placeholder="Generated from name" /> - -