6 Commits

31 changed files with 2508 additions and 688 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/mail
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

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

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/mail
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/mail
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/mail
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/mail
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

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

18
.gitignore vendored
View File

@@ -329,3 +329,21 @@ cython_debug/
# GovOPlaN WebUI test output # GovOPlaN WebUI test output
webui/.mail-test-build/ webui/.mail-test-build/
# GovOPlaN shared ignore rules from govoplan-core
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
*.db
# GovOPlaN local runtime state
runtime/
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -50,7 +50,20 @@ Frontend package:
@govoplan/mail-webui @govoplan/mail-webui
``` ```
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection. The campaign module consumes `mail.campaign_delivery` for sending,
append-to-Sent behavior, profile selection, and policy checks. Mail does not
import campaign internals; campaign-scoped policy and owner context are resolved
through the core `campaigns.mailPolicyContext` capability when the campaign
module is installed.
Development mailbox routes are registered by the mail module only when the
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
does not contribute these routes directly.
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/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,12 @@ class MailCampaignCapability:
append_message_to_sent = staticmethod(append_message_to_sent) append_message_to_sent = staticmethod(append_message_to_sent)
send_email_message = staticmethod(send_email_message) send_email_message = staticmethod(send_email_message)
@staticmethod
def mock_mailbox():
from govoplan_mail.backend.dev import mock_mailbox
return mock_mailbox
def campaign_capability(context: ModuleContext) -> MailCampaignCapability: def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
configure_runtime(settings=context.settings) configure_runtime(settings=context.settings)

View File

@@ -4,7 +4,7 @@ import uuid
from typing import Any from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin from govoplan_core.db.base import Base, TimestampMixin
@@ -21,7 +21,7 @@ class MailServerProfile(Base, TimestampMixin):
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) 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(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, 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) scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -34,9 +34,19 @@ class MailServerProfile(Base, TimestampMixin):
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
imap_username: Mapped[str | None] = mapped_column(String(320)) imap_username: Mapped[str | None] = mapped_column(String(320))
imap_password_encrypted: Mapped[str | None] = mapped_column(Text) 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) 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("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()
class MailProfilePolicy(Base, TimestampMixin):
__tablename__ = "mail_profile_policies"
__table_args__ = (
UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"),
Index("ix_mail_profile_policies_scope", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)

View File

@@ -5,7 +5,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_mail.backend.dev.mock_mailbox import ( from govoplan_mail.backend.dev.mock_mailbox import (
clear_records, clear_records,
get_failures, get_failures,

View File

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

View File

@@ -2,17 +2,24 @@ from __future__ import annotations
import copy import copy
import fnmatch import fnmatch
import json
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Iterable from typing import Any, Iterable
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.admin.governance import get_system_settings from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership from govoplan_core.core.campaigns import (
from govoplan_mail.backend.db.models import MailServerProfile CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
CampaignMailPolicyContext,
CampaignMailPolicyContextProvider,
)
from govoplan_core.core.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 from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
@@ -49,15 +56,6 @@ def default_mail_policy_lower_level_limits() -> dict[str, bool]:
return {key: True for key in MAIL_POLICY_LIMIT_KEYS} return {key: True for key in MAIL_POLICY_LIMIT_KEYS}
def _campaign_model():
try:
from govoplan_campaign.backend.db.models import Campaign
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_campaign")
raise MailProfileError("Campaign module is not installed") from exc
return Campaign
@dataclass(slots=True) @dataclass(slots=True)
class EffectiveCredentialPolicy: class EffectiveCredentialPolicy:
inherit: bool = True inherit: bool = True
@@ -240,6 +238,174 @@ def _policy_from_attr(value: Any) -> dict[str, Any]:
return normalize_mail_profile_policy(value if isinstance(value, dict) else {}) return normalize_mail_profile_policy(value if isinstance(value, dict) else {})
def _json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _access_directory() -> AccessDirectory | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
return None
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise MailProfileError("Access directory capability is invalid")
return capability
def _campaign_policy_provider() -> CampaignMailPolicyContextProvider | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
if not isinstance(capability, CampaignMailPolicyContextProvider):
raise MailProfileError("Campaign mail policy context capability is invalid")
return capability
def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: str) -> CampaignMailPolicyContext:
provider = _campaign_policy_provider()
if provider is None:
raise MailProfileError("Campaign module is not available")
context = provider.get_campaign_mail_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if context is None:
raise MailProfileError("Campaign not found for mail-server profile policy")
return context
def _legacy_tenant_settings(session: Session, tenant_id: str) -> dict[str, Any] | None:
row = session.execute(text("select settings from core_scopes 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 {"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"),
{"scope_id": scope_id, "tenant_id": tenant_id},
).first()
return _json_object(row[0]) if row else None
def _tenant_exists(session: Session, tenant_id: str) -> bool:
return _legacy_tenant_settings(session, tenant_id) is not None
def _user_exists(session: Session, *, tenant_id: str, user_id: str) -> bool:
directory = _access_directory()
if directory is not None:
user = directory.get_user(user_id)
return bool(user and user.tenant_id == tenant_id and user.status == "active")
return _legacy_subject_policy(session, table_name="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:
directory = _access_directory()
if directory is not None:
group = directory.get_group(group_id)
return bool(group and group.tenant_id == tenant_id and group.status == "active")
return _legacy_subject_policy(session, table_name="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]:
clean_scope = _normalize_scope_type(scope_type)
if clean_scope == "system":
return None, clean_scope, None
if clean_scope == "tenant":
return tenant_id, clean_scope, scope_id or tenant_id
return tenant_id, clean_scope, scope_id
def _mail_policy_row(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> MailProfilePolicy | None:
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
query = session.query(MailProfilePolicy).filter(MailProfilePolicy.scope_type == row_scope_type)
query = query.filter(MailProfilePolicy.tenant_id.is_(None) if row_tenant_id is None else MailProfilePolicy.tenant_id == row_tenant_id)
query = query.filter(MailProfilePolicy.scope_id.is_(None) if row_scope_id is None else MailProfilePolicy.scope_id == row_scope_id)
return query.one_or_none()
def _legacy_mail_profile_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> dict[str, Any] | None:
clean_scope = _normalize_scope_type(scope_type)
if clean_scope == "system":
return _policy_from_settings(get_system_settings(session).settings or {})
if clean_scope == "tenant":
if scope_id and scope_id != tenant_id:
return None
settings = _legacy_tenant_settings(session, tenant_id)
return _policy_from_settings(settings or {}) if settings is not None else None
if not scope_id:
return None
if clean_scope == "user":
legacy = _legacy_subject_policy(session, table_name="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="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)
except MailProfileError:
return None
return _policy_from_attr(context.mail_profile_policy)
def _read_mail_profile_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> dict[str, Any] | None:
clean_scope = _normalize_scope_type(scope_type)
if clean_scope != "campaign":
row = _mail_policy_row(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id)
if row is not None:
return _policy_from_attr(row.policy)
return _legacy_mail_profile_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id)
def _write_mail_profile_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
policy: dict[str, Any],
) -> dict[str, Any]:
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
row = _mail_policy_row(session, tenant_id=tenant_id, scope_type=row_scope_type, scope_id=row_scope_id)
if row is None:
row = MailProfilePolicy(
tenant_id=row_tenant_id,
scope_type=row_scope_type,
scope_id=row_scope_id,
policy=policy,
)
else:
row.policy = policy
session.add(row)
return policy
def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False) -> list[str]: def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False) -> list[str]:
fields: list[str] = [] fields: list[str] = []
@@ -298,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]: def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
return { return policy_source_step(
"scope_type": source, source,
"scope_id": source_id, label,
"label": label, source_id,
"applied_fields": _mail_policy_source_fields(policy, baseline=baseline), applied_fields=_mail_policy_source_fields(policy, baseline=baseline),
"policy": _mail_policy_source_policy(policy, baseline=baseline), policy=_mail_policy_source_policy(policy, baseline=baseline),
} )
def _merge_credential_policy( def _merge_credential_policy(
@@ -373,15 +539,13 @@ def _owner_context(
campaign_id: str | None = None, campaign_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> tuple[Any | None, str | None, str | None]: ) -> tuple[CampaignMailPolicyContext | None, str | None, str | None]:
campaign = None campaign_context = None
if campaign_id: if campaign_id:
campaign = session.get(_campaign_model(), campaign_id) campaign_context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if campaign is None or campaign.tenant_id != tenant_id: owner_user_id = campaign_context.owner_user_id
raise MailProfileError("Campaign not found for mail-server profile policy") owner_group_id = campaign_context.owner_group_id
owner_user_id = campaign.owner_user_id return campaign_context, owner_user_id, owner_group_id
owner_group_id = campaign.owner_group_id
return campaign, owner_user_id, owner_group_id
def effective_mail_profile_policy( def effective_mail_profile_policy(
@@ -399,22 +563,22 @@ def effective_mail_profile_policy(
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
owner_group_id=owner_group_id, owner_group_id=owner_group_id,
) )
tenant = session.get(Tenant, tenant_id) tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id)
if tenant is None: if tenant_policy is None:
raise MailProfileError("Tenant not found for mail-server profile policy") raise MailProfileError("Tenant not found for mail-server profile policy")
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session) system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) _merge_policy(policy, system_policy, source="system", label="System", baseline=True)
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant") _merge_policy(policy, tenant_policy, source="tenant", source_id=tenant_id, label="Tenant")
if owner_user_id: if owner_user_id:
user = session.get(User, owner_user_id) user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=owner_user_id)
if user and user.tenant_id == tenant_id: if user_policy is not None:
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user") _merge_policy(policy, user_policy, source="user", source_id=owner_user_id, label="Owner user")
if owner_group_id: if owner_group_id:
group = session.get(Group, owner_group_id) group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=owner_group_id)
if group and group.tenant_id == tenant_id: if group_policy is not None:
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group") _merge_policy(policy, group_policy, source="group", source_id=owner_group_id, label="Owner group")
if campaign is not None: if campaign is not None:
_merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign") _merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign")
return policy return policy
@@ -434,8 +598,8 @@ def effective_mail_profile_policy_for_scope(
return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if clean_scope == "system": if clean_scope == "system":
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session) system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) _merge_policy(policy, system_policy, source="system", label="System", baseline=True)
return policy return policy
if clean_scope == "tenant": if clean_scope == "tenant":
return effective_mail_profile_policy(session, tenant_id=tenant_id) return effective_mail_profile_policy(session, tenant_id=tenant_id)
@@ -460,33 +624,31 @@ def parent_mail_profile_policy(
scope_id: str | None = None, scope_id: str | None = None,
) -> EffectiveMailProfilePolicy: ) -> EffectiveMailProfilePolicy:
clean_scope = _normalize_scope_type(scope_type) clean_scope = _normalize_scope_type(scope_type)
tenant = session.get(Tenant, tenant_id) tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id)
if tenant is None: if tenant_policy is None:
raise MailProfileError("Tenant not found for mail-server profile policy") raise MailProfileError("Tenant not found for mail-server profile policy")
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session) system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) _merge_policy(policy, system_policy, source="system", label="System", baseline=True)
if clean_scope == "tenant": if clean_scope == "tenant":
return policy return policy
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant") _merge_policy(policy, tenant_policy, source="tenant", source_id=tenant_id, label="Tenant")
if clean_scope in {"user", "group"}: if clean_scope in {"user", "group"}:
return policy return policy
if clean_scope != "campaign" or not scope_id: if clean_scope != "campaign" or not scope_id:
return policy return policy
campaign = session.get(_campaign_model(), scope_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
if campaign.owner_user_id: if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id) user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=campaign.owner_user_id)
if user and user.tenant_id == tenant_id: if user_policy is not None:
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user") _merge_policy(policy, user_policy, source="user", source_id=campaign.owner_user_id, label="Owner user")
if campaign.owner_group_id: if campaign.owner_group_id:
group = session.get(Group, campaign.owner_group_id) group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=campaign.owner_group_id)
if group and group.tenant_id == tenant_id: if group_policy is not None:
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group") _merge_policy(policy, group_policy, source="group", source_id=campaign.owner_group_id, label="Owner group")
return policy return policy
@@ -580,22 +742,18 @@ def _scope_tuple_for_create(
target_id = clean_scope_id or user_id target_id = clean_scope_id or user_id
if not target_id: if not target_id:
raise MailProfileError("User-scoped mail-server profiles require a user scope_id") raise MailProfileError("User-scoped mail-server profiles require a user scope_id")
user = session.get(User, target_id) if not _user_exists(session, tenant_id=tenant_id, user_id=target_id):
if user is None or user.tenant_id != tenant_id:
raise MailProfileError("User scope is not part of the active tenant") raise MailProfileError("User scope is not part of the active tenant")
return tenant_id, clean_scope, target_id return tenant_id, clean_scope, target_id
if clean_scope == "group": if clean_scope == "group":
if not clean_scope_id: if not clean_scope_id:
raise MailProfileError("Group-scoped mail-server profiles require a group scope_id") raise MailProfileError("Group-scoped mail-server profiles require a group scope_id")
group = session.get(Group, clean_scope_id) if not _group_exists(session, tenant_id=tenant_id, group_id=clean_scope_id):
if group is None or group.tenant_id != tenant_id:
raise MailProfileError("Group scope is not part of the active tenant") raise MailProfileError("Group scope is not part of the active tenant")
return tenant_id, clean_scope, clean_scope_id return tenant_id, clean_scope, clean_scope_id
if not clean_scope_id: if not clean_scope_id:
raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id") raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id")
campaign = session.get(_campaign_model(), clean_scope_id) _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=clean_scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign scope is not part of the active tenant")
return tenant_id, clean_scope, clean_scope_id return tenant_id, clean_scope, clean_scope_id
@@ -647,7 +805,7 @@ def _profile_visible_filter(tenant_id: str):
return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id) return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id)
def _context_profile_clauses(campaign: Any, policy: EffectiveMailProfilePolicy) -> list[Any]: def _context_profile_clauses(campaign: CampaignMailPolicyContext, policy: EffectiveMailProfilePolicy) -> list[Any]:
clauses: list[Any] = [ clauses: list[Any] = [
MailServerProfile.scope_type == "system", MailServerProfile.scope_type == "system",
and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"), and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"),
@@ -674,7 +832,7 @@ def _profile_allowed_for_context(
*, *,
tenant_id: str, tenant_id: str,
policy: EffectiveMailProfilePolicy, policy: EffectiveMailProfilePolicy,
campaign: Any | None = None, campaign: CampaignMailPolicyContext | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> bool: ) -> bool:
@@ -711,9 +869,7 @@ def list_mail_server_profiles(
campaign_id: str | None = None, campaign_id: str | None = None,
) -> list[MailServerProfile]: ) -> list[MailServerProfile]:
if campaign_id: if campaign_id:
campaign = session.get(_campaign_model(), campaign_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profiles")
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy))) query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy)))
if not include_inactive: if not include_inactive:
@@ -762,9 +918,7 @@ def ensure_mail_profile_allowed_for_campaign(
profile_id: str, profile_id: str,
require_active: bool = True, require_active: bool = True,
) -> MailServerProfile: ) -> MailServerProfile:
campaign = session.get(_campaign_model(), campaign_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy")
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active) profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
if not _profile_allowed_for_context( if not _profile_allowed_for_context(
@@ -1165,27 +1319,25 @@ def get_mail_profile_policy(
) -> dict[str, Any]: ) -> dict[str, Any]:
clean_scope = _normalize_scope_type(scope_type) clean_scope = _normalize_scope_type(scope_type)
if clean_scope == "system": if clean_scope == "system":
return _policy_from_settings(get_system_settings(session).settings or {}) return _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
if clean_scope == "tenant": if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id) policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id: if policy is None:
raise MailProfileError("Tenant policy not found") raise MailProfileError("Tenant policy not found")
return _policy_from_settings(tenant.settings or {}) return policy
if not scope_id: if not scope_id:
raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id")
if clean_scope == "user": if clean_scope == "user":
user = session.get(User, scope_id) policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id)
if user is None or user.tenant_id != tenant_id: if policy is None:
raise MailProfileError("User policy not found") raise MailProfileError("User policy not found")
return _policy_from_attr(user.mail_profile_policy) return policy
if clean_scope == "group": if clean_scope == "group":
group = session.get(Group, scope_id) policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id)
if group is None or group.tenant_id != tenant_id: if policy is None:
raise MailProfileError("Group policy not found") raise MailProfileError("Group policy not found")
return _policy_from_attr(group.mail_profile_policy) return policy
campaign = session.get(_campaign_model(), scope_id) campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
return _policy_from_attr(campaign.mail_profile_policy) return _policy_from_attr(campaign.mail_profile_policy)
@@ -1231,42 +1383,28 @@ def set_mail_profile_policy(
normalized, normalized,
) )
if clean_scope == "system": if clean_scope == "system":
item = get_system_settings(session) return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system", scope_id=None, policy=normalized)
settings_payload = dict(item.settings or {})
settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized
item.settings = settings_payload
session.add(item)
return normalized
if clean_scope == "tenant": if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id) if (scope_id and scope_id != tenant_id) or not _tenant_exists(session, tenant_id):
if tenant is None or tenant.id != tenant_id:
raise MailProfileError("Tenant policy not found") raise MailProfileError("Tenant policy not found")
settings_payload = dict(tenant.settings or {}) return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id, policy=normalized)
settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized
tenant.settings = settings_payload
session.add(tenant)
return normalized
if not scope_id: if not scope_id:
raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id") raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id")
if clean_scope == "user": if clean_scope == "user":
user = session.get(User, scope_id) if not _user_exists(session, tenant_id=tenant_id, user_id=scope_id):
if user is None or user.tenant_id != tenant_id:
raise MailProfileError("User policy not found") raise MailProfileError("User policy not found")
user.mail_profile_policy = normalized return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id, policy=normalized)
session.add(user)
return normalized
if clean_scope == "group": if clean_scope == "group":
group = session.get(Group, scope_id) if not _group_exists(session, tenant_id=tenant_id, group_id=scope_id):
if group is None or group.tenant_id != tenant_id:
raise MailProfileError("Group policy not found") raise MailProfileError("Group policy not found")
group.mail_profile_policy = normalized return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id, policy=normalized)
session.add(group) provider = _campaign_policy_provider()
return normalized if provider is None:
campaign = session.get(_campaign_model(), scope_id) raise MailProfileError("Campaign module is not available")
if campaign is None or campaign.tenant_id != tenant_id: try:
raise MailProfileError("Campaign policy not found") provider.set_campaign_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id, policy=normalized)
campaign.mail_profile_policy = normalized except ValueError as exc:
session.add(campaign) raise MailProfileError("Campaign policy not found") from exc
return normalized return normalized
@@ -1354,10 +1492,11 @@ def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str |
def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]: def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]:
return [ directory = _access_directory()
row.group_id if directory is not None:
for row in session.query(UserGroupMembership).filter( return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
UserGroupMembership.tenant_id == tenant_id, rows = session.execute(
UserGroupMembership.user_id == user_id, text("select group_id from 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]

View File

@@ -2,8 +2,24 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base 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 from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
@@ -57,17 +73,36 @@ def _mail_router(context: ModuleContext):
from govoplan_mail.backend.runtime import configure_runtime from govoplan_mail.backend.runtime import configure_runtime
configure_runtime(settings=context.settings) configure_runtime(settings=context.settings)
from fastapi import APIRouter
from govoplan_mail.backend.router import router from govoplan_mail.backend.router import router
return router aggregate = APIRouter()
aggregate.include_router(router)
app_env = str(getattr(context.settings, "app_env", "dev")).lower()
if app_env == "dev" and bool(getattr(context.settings, "dev_mailbox_api_enabled", False)):
from govoplan_mail.backend.dev_router import router as dev_router
aggregate.include_router(dev_router)
return aggregate
manifest = ModuleManifest( manifest = ModuleManifest(
id="mail", id="mail",
name="Mail", name="Mail",
version="0.1.4", version="0.1.6",
dependencies=("access",), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=(), optional_dependencies=("campaigns",),
provides_interfaces=(
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="campaigns.mail_policy_context",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_mail_router, route_factory=_mail_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
@@ -81,10 +116,53 @@ manifest = ModuleManifest(
module_id="mail", module_id="mail",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
mail_models.MailServerProfile,
mail_models.MailProfilePolicy,
label="Mail",
),
retirement_notes="Destructive retirement drops mail-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
mail_models.MailServerProfile,
mail_models.MailProfilePolicy,
label="Mail",
),
), ),
capability_factories={ capability_factories={
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
}, },
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,),
) )

View File

@@ -0,0 +1,56 @@
"""mail profile policies
Revision ID: 3d4e5f708192
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-06 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "3d4e5f708192"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def _scope_fk_target(inspector) -> str:
tables = set(inspector.get_table_names())
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "mail_profile_policies" in inspector.get_table_names():
return
scope_fk_target = _scope_fk_target(inspector)
op.create_table(
"mail_profile_policies",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("policy", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_mail_profile_policies_tenant_id_scopes"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_profile_policies")),
sa.UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"),
)
op.create_index(op.f("ix_mail_profile_policies_tenant_id"), "mail_profile_policies", ["tenant_id"], unique=False)
op.create_index(op.f("ix_mail_profile_policies_scope_type"), "mail_profile_policies", ["scope_type"], unique=False)
op.create_index(op.f("ix_mail_profile_policies_scope_id"), "mail_profile_policies", ["scope_id"], unique=False)
op.create_index("ix_mail_profile_policies_scope", "mail_profile_policies", ["scope_type", "scope_id"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "mail_profile_policies" not in inspector.get_table_names():
return
op.drop_index("ix_mail_profile_policies_scope", table_name="mail_profile_policies")
op.drop_index(op.f("ix_mail_profile_policies_scope_id"), table_name="mail_profile_policies")
op.drop_index(op.f("ix_mail_profile_policies_scope_type"), table_name="mail_profile_policies")
op.drop_index(op.f("ix_mail_profile_policies_tenant_id"), table_name="mail_profile_policies")
op.drop_table("mail_profile_policies")

View File

@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import ( from govoplan_mail.backend.schemas import (
MailConnectionTestResponse, MailConnectionTestResponse,
@@ -14,13 +16,23 @@ from govoplan_mail.backend.schemas import (
MailMailboxMessageSummaryResponse, MailMailboxMessageSummaryResponse,
MailProfilePolicyResponse, MailProfilePolicyResponse,
MailProfilePolicyUpdateRequest, MailProfilePolicyUpdateRequest,
MailSettingsDeltaResponse,
MailServerProfileCreateRequest, MailServerProfileCreateRequest,
MailServerProfileListResponse, MailServerProfileListResponse,
MailServerProfileResponse, MailServerProfileResponse,
MailServerProfileUpdateRequest, MailServerProfileUpdateRequest,
MailSmtpTestRequest, MailSmtpTestRequest,
) )
from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal, has_scope from govoplan_core.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_core.db.session import get_session
from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.mail_profiles import (
MailProfileError, MailProfileError,
@@ -39,10 +51,18 @@ from govoplan_mail.backend.mail_profiles import (
from govoplan_mail.backend.config import ImapConfig, SmtpConfig 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.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 govoplan_mail.backend.sending.smtp import test_smtp_login
from sqlalchemy.orm import Session
router = APIRouter(prefix="/mail", tags=["mail"]) 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: def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope): if not has_scope(principal, scope):
@@ -95,6 +115,122 @@ def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile)) 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: def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
return MailMailboxMessageSummaryResponse( return MailMailboxMessageSummaryResponse(
uid=message.uid, 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): 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) profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
imap = imap_config_from_profile(profile) 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() 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) @router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles( def list_profiles(
include_inactive: bool = False, include_inactive: bool = False,
@@ -189,12 +493,22 @@ def create_profile(
scope_type=payload.scope_type, scope_type=payload.scope_type,
scope_id=payload.scope_id, 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.commit()
session.refresh(profile) session.refresh(profile)
return _profile_response(profile) return _profile_response(profile)
except MailProfileError as exc: except MailProfileError as exc:
session.rollback() 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) @router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
@@ -245,12 +559,22 @@ def update_profile(
imap=imap_config, imap=imap_config,
clear_imap=payload.clear_imap, 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.commit()
session.refresh(profile) session.refresh(profile)
return _profile_response(profile) return _profile_response(profile)
except MailProfileError as exc: except MailProfileError as exc:
session.rollback() 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) @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") _require_profile_write_scope(principal, profile.scope_type or "tenant")
profile.is_active = False profile.is_active = False
session.add(profile) 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.commit()
session.refresh(profile) session.refresh(profile)
return _profile_response(profile) return _profile_response(profile)
@@ -283,20 +617,7 @@ def read_mail_profile_policy(
scope_type = scope_type.strip().casefold() scope_type = scope_type.strip().casefold()
_require_policy_read_scope(principal, scope_type) _require_policy_read_scope(principal, scope_type)
try: try:
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_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)
except MailProfileError as exc: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from 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, scope_id=scope_id,
policy=payload.policy.model_dump(mode="json"), policy=payload.policy.model_dump(mode="json"),
) )
session.commit() _record_mail_change(
effective_policy = effective_mail_profile_policy_for_scope(
session, session,
tenant_id=principal.tenant_id, collection=MAIL_POLICIES_COLLECTION,
scope_type=scope_type, resource_type=MAIL_POLICY_RESOURCE,
scope_id=scope_id, 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() session.commit()
effective_sources = effective_policy.source_policies return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
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: except MailProfileError as exc:
session.rollback() 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) @router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
@@ -414,7 +734,7 @@ def list_profile_mailbox_folders(
except MailProfileError as exc: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as 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: except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from 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( def list_profile_mailbox_messages(
profile_id: str, profile_id: str,
folder: str = Query(default="INBOX", min_length=1, max_length=255), 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), offset: int = Query(default=0, ge=0, le=100000),
cursor: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_mailbox_read_scope(principal) _require_mailbox_read_scope(principal)
try: try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) 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( return MailMailboxMessageListResponse(
profile_id=profile_id, profile_id=profile_id,
folder=result.folder, folder=result.folder,
@@ -441,12 +783,16 @@ def list_profile_mailbox_messages(
total_count=result.total_count, total_count=result.total_count,
offset=result.offset, offset=result.offset,
limit=result.limit, 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], messages=[_mailbox_summary_response(message) for message in result.messages],
) )
except MailProfileError as exc: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as 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: except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from 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: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as 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: except ImapAppendError as exc:
status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).casefold() else status.HTTP_502_BAD_GATEWAY 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 raise HTTPException(status_code=status_code, detail=str(exc)) from exc

View File

@@ -5,6 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator 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 from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials
@@ -93,6 +94,7 @@ class MailProfilePolicyUpdateRequest(BaseModel):
class PolicySourceStepResponse(BaseModel): class PolicySourceStepResponse(BaseModel):
scope_type: str scope_type: str
scope_id: str | None = None scope_id: str | None = None
path: str
label: str label: str
applied_fields: list[str] = Field(default_factory=list) applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict) policy: dict[str, Any] = Field(default_factory=dict)
@@ -201,6 +203,16 @@ class MailServerProfileListResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list) 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): class MailConnectionTestResponse(BaseModel):
ok: bool ok: bool
protocol: Literal["smtp", "imap"] protocol: Literal["smtp", "imap"]
@@ -266,6 +278,10 @@ class MailMailboxMessageListResponse(BaseModel):
total_count: int = 0 total_count: int = 0
offset: int = 0 offset: int = 0
limit: int = 50 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) messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
@@ -276,4 +292,3 @@ class MailMailboxMessageResponse(BaseModel):
port: int | None = None port: int | None = None
security: str | None = None security: str | None = None
message: MailMailboxMessageDetailResponse message: MailMailboxMessageDetailResponse

View File

@@ -115,6 +115,8 @@ class ImapMailboxMessageListResult:
total_count: int total_count: int
offset: int offset: int
limit: int limit: int
uidvalidity: str | None = None
cursor_reset: bool = False
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -527,15 +529,23 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -
return parts 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) typ, data = client.select(folder, readonly=True)
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) 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() selected_count = _decode_item(data[0] if data else None).strip()
try: try:
return max(0, int(selected_count)) total_count = max(0, int(selected_count))
except ValueError: 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]: 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 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: def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip() folder = str(record.get("folder") or "").strip()
if folder: if folder:
@@ -612,7 +679,15 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace") 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.""" """List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config) 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) offset = max(0, offset)
if is_mock_imap_host(imap_config.host): if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] 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 = [ messages = [
_message_summary_from_raw( _message_summary_from_raw(
uid=str(record.get("id") or ""), 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 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) client = _open_imap(imap_config)
try: try:
total_count = _select_readonly(client, folder) total_count, uidvalidity = _select_readonly(client, folder)
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit) uids = _search_message_uids(client)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder) cursor_reset = False
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit) 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: finally:
try: try:
client.logout() client.logout()

View File

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

View File

@@ -1,4 +1,4 @@
import type { ApiSettings } from "../types"; import type { ApiSettings, DeltaDeletedItem } from "../types";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls"; export type MailSecurity = "plain" | "tls" | "starttls";
@@ -95,6 +95,10 @@ export type MailMailboxMessageListResponse = {
total_count?: number; total_count?: number;
offset?: number; offset?: number;
limit?: number; limit?: number;
cursor?: string | null;
next_cursor?: string | null;
cursor_stable?: boolean;
full?: boolean;
messages: MailMailboxMessageSummary[]; messages: MailMailboxMessageSummary[];
}; };
@@ -127,6 +131,16 @@ export type MailServerProfile = {
export type MailServerProfileListResponse = { profiles: 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 = [ export const mailProfilePatternKeys = [
"smtp_hosts", "smtp_hosts",
"imap_hosts", "imap_hosts",
@@ -215,6 +229,28 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
return response.profiles ?? []; 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<MailSettingsDeltaResponse> {
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<MailSettingsDeltaResponse>(settings, `/api/v1/mail/settings/delta${suffix}`);
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> { export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", { return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
method: "POST", method: "POST",
@@ -284,9 +320,11 @@ export async function listMailboxMessages(
profileId: string, profileId: string,
folder = "INBOX", folder = "INBOX",
limit = 50, limit = 50,
offset = 0 offset = 0,
cursor?: string | null
): Promise<MailMailboxMessageListResponse> { ): Promise<MailMailboxMessageListResponse> {
const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) }); const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) });
if (cursor) params.set("cursor", cursor);
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`); return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import {
LoadingIndicator, LoadingIndicator,
MessageDisplayPanel, MessageDisplayPanel,
formatDateTime, formatDateTime,
i18nMessage,
type ApiSettings type ApiSettings
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -17,11 +18,11 @@ import {
type MailImapFolderResponse, type MailImapFolderResponse,
type MailMailboxMessageDetail, type MailMailboxMessageDetail,
type MailMailboxMessageSummary, type MailMailboxMessageSummary,
type MailServerProfile type MailServerProfile } from
} from "../../api/mail"; "../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders"; import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
export default function MailboxPage({ settings }: { settings: ApiSettings }) { export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]); const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState(""); const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]); const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
@@ -48,6 +49,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const folderRequestRef = useRef(0); const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0); const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0); const messageDetailRequestRef = useRef(0);
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null; const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]); const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
@@ -60,16 +62,16 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId; const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : ""); const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady); const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = folderError || (noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available."); const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
const messageEmptyText = messageError || (!selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : messages.length > 0 && filteredMessages.length === 0 ? "No messages match the current filter on this page." : "No messages in this folder."); const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
const previewEmptyText = detailError || (loadingMessage ? "Loading message..." : "Select a message to inspect its content."); const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message..."; const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => { selectedMessageKeyRef.current = selectedMessageKeyState; }, [selectedMessageKeyState]); useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
useEffect(() => { if (messagePage > messagePageCount) setMessagePage(messagePageCount); }, [messagePage, messagePageCount]); useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]); useEffect(() => {if (selectedProfileId) void loadFolders(selectedProfileId);}, [selectedProfileId]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize); }, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]); useEffect(() => {if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => { useEffect(() => {
const handlePreviewShortcut = (event: KeyboardEvent) => { const handlePreviewShortcut = (event: KeyboardEvent) => {
@@ -88,10 +90,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (shellBusy || loadingMessage || filteredMessages.length === 0) return; if (shellBusy || loadingMessage || filteredMessages.length === 0) return;
const selectedIndex = filteredMessages.findIndex((message) => mailboxMessageKey(message.folder || selectedFolder, message.uid) === selectedMessageKey); const selectedIndex = filteredMessages.findIndex((message) => mailboxMessageKey(message.folder || selectedFolder, message.uid) === selectedMessageKey);
let nextIndex: number | null = null; let nextIndex: number | null = null;
if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0; if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;else
else if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0; if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;else
else if (event.key === "Home") nextIndex = 0; if (event.key === "Home") nextIndex = 0;else
else if (event.key === "End") nextIndex = filteredMessages.length - 1; if (event.key === "End") nextIndex = filteredMessages.length - 1;
if (nextIndex === null) return; if (nextIndex === null) return;
event.preventDefault(); event.preventDefault();
const nextMessage = filteredMessages[nextIndex]; const nextMessage = filteredMessages[nextIndex];
@@ -155,7 +157,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
try { try {
const response = await listMailboxFolders(settings, profileId); const response = await listMailboxFolders(settings, profileId);
if (requestId !== folderRequestRef.current) return; if (requestId !== folderRequestRef.current) return;
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded."); if (!response.ok) throw new Error(response.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }]; const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
setFolders(loaded); setFolders(loaded);
setExpandedFolders(new Set()); setExpandedFolders(new Set());
@@ -185,15 +187,19 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (!profileId || !folder) return; if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current; const requestId = ++messageListRequestRef.current;
const offset = (Math.max(1, page) - 1) * pageSize; const offset = (Math.max(1, page) - 1) * pageSize;
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true); setLoadingMessages(true);
setMessageError(""); setMessageError("");
setDetailError(""); setDetailError("");
setError(""); setError("");
try { try {
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset); const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor);
if (requestId !== messageListRequestRef.current) return; if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? []; const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length; const total = response.total_count ?? loaded.length;
if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null;
if (response.next_cursor) mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor;
setMessages(loaded); setMessages(loaded);
setMessageTotalCount(total); setMessageTotalCount(total);
setFolderMessageCount(folder, total); setFolderMessageCount(folder, total);
@@ -269,8 +275,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function openFolderNode(node: MailFolderNode) { function openFolderNode(node: MailFolderNode) {
if (node.folderName) { if (node.folderName) {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize); if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else
else { {
setSelectedFolder(node.folderName); setSelectedFolder(node.folderName);
setMessagePage(1); setMessagePage(1);
setSelectedMessage(null); setSelectedMessage(null);
@@ -287,8 +293,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function toggleFolderNode(node: MailFolderNode) { function toggleFolderNode(node: MailFolderNode) {
setExpandedFolders((current) => { setExpandedFolders((current) => {
const next = new Set(current); const next = new Set(current);
if (next.has(node.id)) next.delete(node.id); if (next.has(node.id)) next.delete(node.id);else
else next.add(node.id); next.add(node.id);
return next; return next;
}); });
} }
@@ -312,8 +318,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}> <div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="Mailbox folders"> <aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">Folders</div> <div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
<div className="file-tree-list"> <div className="file-tree-list">
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>} {folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
<ExplorerTree <ExplorerTree
@@ -336,70 +342,70 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>} {showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
</span> </span>
{flagText && <small>{flagText}</small>} {flagText && <small>{flagText}</small>}
</span> </span>);
);
}} }} />
/>
</div> </div>
</aside> </aside>
<section className="file-list-panel mailbox-message-list-panel" aria-label="Mailbox messages"> <section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
<div className="file-list-sticky"> <div className="file-list-sticky">
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions"> <div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
<label className="mailbox-profile-field"> <label className="mailbox-profile-field">
<span>IMAP profile</span> <span>i18n:govoplan-mail.imap_profile.5165df81</span>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}> <select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>} {imapProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_imap_profiles_available.d64589f8</option>}
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)} {imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select> </select>
</label> </label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span> <span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
<div className="mailbox-toolbar-actions"> <div className="mailbox-toolbar-actions">
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="Reload IMAP profiles"> <Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
Profiles i18n:govoplan-mail.profiles.0c2a9300
</Button> </Button>
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || loadingFolders} title="Refresh mailbox folders"> <Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || loadingFolders} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
Folders i18n:govoplan-mail.folders.19adc47b
</Button> </Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="Refresh messages in the current folder"> <Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
Messages i18n:govoplan-mail.messages.f1702b46
</Button> </Button>
</div> </div>
</div> </div>
<nav className="file-breadcrumbs" aria-label="Current mailbox folder"> <nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "Mail"}</span> <span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "i18n:govoplan-mail.mail.92379cbb"}</span>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span> <span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
</nav> </nav>
<div className="mailbox-filter-row"> <div className="mailbox-filter-row">
<label className="mailbox-search-field"> <label className="mailbox-search-field">
<Search size={15} aria-hidden="true" /> <Search size={15} aria-hidden="true" />
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="Search messages" /> <input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="i18n:govoplan-mail.search_messages.abea65ae" />
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="Clear message search"><X size={14} /></button>} {messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="i18n:govoplan-mail.clear_message_search.cc9f2800"><X size={14} /></button>}
</label> </label>
</div> </div>
<div className="file-list-meta"> <div className="file-list-meta">
<span>{messageCountLabel}</span> <span>{messageCountLabel}</span>
<span>{selectedFolder}</span> <span>{selectedFolder}</span>
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} on page</span>} {messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
{shellBusy && <span>Working...</span>} {shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
{loadingMessage && <span>Loading preview...</span>} {loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
</div> </div>
<div className="file-list-table-head mailbox-message-head" role="row"> <div className="file-list-table-head mailbox-message-head" role="row">
<span>Subject</span> <span>i18n:govoplan-mail.subject.8d183dbd</span>
<span>Date</span> <span>i18n:govoplan-mail.date.eb9a4bc1</span>
<span>Size</span> <span>i18n:govoplan-mail.size.b7152342</span>
</div> </div>
</div> </div>
<div className="file-list-drop-target mailbox-message-scroll"> <div className="file-list-drop-target mailbox-message-scroll">
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages"> <div className="file-list-table mailbox-message-table" role="table" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
{filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>} {filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>}
{filteredMessages.map((message) => { {filteredMessages.map((message) => {
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid); const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
@@ -417,13 +423,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
event.preventDefault(); event.preventDefault();
void openMessage(message); void openMessage(message);
} }
}} }}>
>
<div className="file-list-name-cell"> <div className="file-list-name-cell">
<div className="file-list-name"> <div className="file-list-name">
<Mail className="file-row-icon" size={20} aria-hidden="true" /> <Mail className="file-row-icon" size={20} aria-hidden="true" />
<span> <span>
<strong>{message.subject || "(no subject)"}</strong> <strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
<small>{message.from_header || "-"}</small> <small>{message.from_header || "-"}</small>
</span> </span>
</div> </div>
@@ -433,8 +439,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null} {message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
<span>{formatBytes(message.size_bytes)}</span> <span>{formatBytes(message.size_bytes)}</span>
</span> </span>
</div> </div>);
);
})} })}
</div> </div>
</div> </div>
@@ -444,22 +450,22 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
totalRows={messageTotalCount ?? 0} totalRows={messageTotalCount ?? 0}
disabled={loadingMessages || !foldersReady} disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage} onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize} onPageSizeChange={changeMessagePageSize} />
/>
</section> </section>
<section className="mailbox-preview-panel" aria-label="Message preview"> <section className="mailbox-preview-panel" aria-label="i18n:govoplan-mail.message_preview.58de1450">
<div className="file-tree-heading">Preview</div> <div className="file-tree-heading">i18n:govoplan-mail.preview.f1fbb2b4</div>
<div className="mailbox-preview-scroll"> <div className="mailbox-preview-scroll">
<MessageDisplayPanel <MessageDisplayPanel
title={selectedMessage?.subject} title={selectedMessage?.subject}
fields={selectedMessage ? [ fields={selectedMessage ? [
{ label: "From", value: selectedMessage.from_header || "-" }, { label: "i18n:govoplan-mail.from.3f66052a", value: selectedMessage.from_header || "-" },
{ label: "To", value: selectedMessage.to_header || "-" }, { label: "i18n:govoplan-mail.to.ae79ea1e", value: selectedMessage.to_header || "-" },
{ label: "Cc", value: selectedMessage.cc_header || null }, { label: "i18n:govoplan-mail.cc.1fd6a880", value: selectedMessage.cc_header || null },
{ label: "Bcc", value: selectedMessage.bcc_header || null }, { label: "i18n:govoplan-mail.bcc.8431acad", value: selectedMessage.bcc_header || null },
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) } { label: "i18n:govoplan-mail.date.eb9a4bc1", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }] :
] : []} []}
bodyText={selectedMessage?.body_text} bodyText={selectedMessage?.body_text}
bodyHtml={selectedMessage?.body_html} bodyHtml={selectedMessage?.body_html}
bodyPreview={selectedMessage?.body_preview} bodyPreview={selectedMessage?.body_preview}
@@ -469,70 +475,74 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
contentType: attachment.content_type, contentType: attachment.content_type,
sizeBytes: attachment.size_bytes sizeBytes: attachment.size_bytes
}))} }))}
emptyText={previewEmptyText} emptyText={previewEmptyText} />
/>
</div> </div>
</section> </section>
{shellBusy && ( {shellBusy &&
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true"> <div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div> <div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{i18nMessage(loadingLabel)}</span></div>
</div> </div>
)} }
</div> </div>
</div> </div>);
);
} }
function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: { page: number; pageSize: number; totalRows: number; disabled: boolean; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void }) { function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: {page: number;pageSize: number;totalRows: number;disabled: boolean;onPageChange: (page: number) => void;onPageSizeChange: (pageSize: number) => void;}) {
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1; const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(totalRows, page * pageSize); const last = Math.min(totalRows, page * pageSize);
const options = [10, 25, 50, 100]; const options = [10, 25, 50, 100];
return ( return (
<div className="data-grid-pagination mailbox-pagination" aria-label="Mailbox message pagination"> <div className="data-grid-pagination mailbox-pagination" aria-label="i18n:govoplan-mail.mailbox_message_pagination.965407bf">
<div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div> <div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div>
<label className="data-grid-page-size"> <label className="data-grid-page-size">
<span>Rows per page</span> <span>i18n:govoplan-mail.rows_per_page.af2f9c1b</span>
<select value={pageSize} disabled={disabled} onChange={(event) => onPageSizeChange(Number(event.target.value))}> <select value={pageSize} disabled={disabled} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
{options.map((value) => <option key={value} value={value}>{value}</option>)} {options.map((value) => <option key={value} value={value}>{value}</option>)}
</select> </select>
</label> </label>
<div className="data-grid-page-controls"> <div className="data-grid-page-controls">
<button type="button" aria-label="First page" disabled={disabled || page <= 1} onClick={() => onPageChange(1)}><ChevronsLeft size={16} /></button> <button type="button" aria-label="i18n:govoplan-mail.first_page.49d74b49" disabled={disabled || page <= 1} onClick={() => onPageChange(1)}><ChevronsLeft size={16} /></button>
<button type="button" aria-label="Previous page" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button> <button type="button" aria-label="i18n:govoplan-mail.previous_page.81f54719" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
<span>Page {page} of {pageCount}</span> <span>i18n:govoplan-mail.page.fb06270f {page} of {pageCount}</span>
<button type="button" aria-label="Next page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(page + 1)}><ChevronRight size={16} /></button> <button type="button" aria-label="i18n:govoplan-mail.next_page.4bfc194b" disabled={disabled || page >= pageCount} onClick={() => onPageChange(page + 1)}><ChevronRight size={16} /></button>
<button type="button" aria-label="Last page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button> <button type="button" aria-label="i18n:govoplan-mail.last_page.b01f16ae" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button>
</div> </div>
</div> </div>);
);
} }
function mailboxMessageKey(folder: string, uid: string): string { function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`; return `${folder || "INBOX"}::${uid}`;
} }
function mailboxCursorKey(profileId: string, folder: string, pageSize: number): string {
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
}
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] { function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
const normalized = query.trim().toLocaleLowerCase(); const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return messages; if (!normalized) return messages;
return messages.filter((message) => [ return messages.filter((message) => [
message.subject, message.subject,
message.from_header, message.from_header,
message.to_header, message.to_header,
message.cc_header, message.cc_header,
message.date, message.date,
message.body_preview message.body_preview].
].some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized))); some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
} }
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string { function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
if (!ready) return "No folder loaded"; if (!ready) return "i18n:govoplan-mail.no_folder_loaded.889bdb1b";
if (loading) return "Loading messages"; if (loading) return "i18n:govoplan-mail.loading_messages.4294022c";
if (totalCount !== null && totalCount > visibleCount) return `${visibleCount} of ${totalCount} messages`; if (totalCount !== null && totalCount > visibleCount) return i18nMessage("i18n:govoplan-mail.value_of_value_messages.981a1618", { value0: visibleCount, value1: totalCount });
const count = totalCount ?? visibleCount; const count = totalCount ?? visibleCount;
return `${count} message${count === 1 ? "" : "s"}`; return i18nMessage(count === 1 ? "i18n:govoplan-mail.value_message" : "i18n:govoplan-mail.value_messages", { value0: count });
} }
function folderFlagLabel(flags: string[]): string { function folderFlagLabel(flags: string[]): string {
@@ -544,17 +554,17 @@ function displayFolderFlag(flag: string): string | null {
const normalized = flag.replace(/^\\/, "").toLowerCase(); const normalized = flag.replace(/^\\/, "").toLowerCase();
switch (normalized) { switch (normalized) {
case "sent": case "sent":
return "Sent"; return "i18n:govoplan-mail.sent.35f49dcf";
case "drafts": case "drafts":
return "Drafts"; return "i18n:govoplan-mail.drafts.22a31d86";
case "trash": case "trash":
return "Trash"; return "i18n:govoplan-mail.trash.e3bf62bb";
case "archive": case "archive":
return "Archive"; return "i18n:govoplan-mail.archive.2621c6fd";
case "junk": case "junk":
return "Junk"; return "i18n:govoplan-mail.junk.86c7d94c";
case "flagged": case "flagged":
return "Flagged"; return "i18n:govoplan-mail.flagged.f8db8a17";
case "haschildren": case "haschildren":
case "hasnochildren": case "hasnochildren":
case "noselect": case "noselect":
@@ -566,15 +576,15 @@ function displayFolderFlag(flag: string): string | null {
function transportLabel(profile: MailServerProfile): string { function transportLabel(profile: MailServerProfile): string {
const imap = profile.imap; const imap = profile.imap;
if (!imap?.host) return "IMAP not configured"; if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3";
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim(); return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
} }
function formatBytes(value?: number | null): string { function formatBytes(value?: number | null): string {
if (!value) return "-"; if (!value) return "-";
if (value < 1024) return `${value} B`; if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-mail.bytes_kb", { value0: (value / 1024).toFixed(1) });
return `${(value / 1024 / 1024).toFixed(1)} MB`; return i18nMessage("i18n:govoplan-mail.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
} }
function errorText(error: unknown): string { function errorText(error: unknown): string {

View File

@@ -22,11 +22,11 @@ export type MailPolicyValidationMessage = {
}; };
const patternLabels: Record<MailProfilePatternKey, string> = { const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "SMTP host", smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b",
imap_hosts: "IMAP host", imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
envelope_senders: "Envelope sender", envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
from_headers: "From header", from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
recipient_domains: "Recipient domain" recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
}; };
type ValueCheck = { type ValueCheck = {
@@ -35,19 +35,19 @@ type ValueCheck = {
}; };
export function validateMailPolicy( export function validateMailPolicy(
policy: MailProfilePolicy | null | undefined, policy: MailProfilePolicy | null | undefined,
input: MailPolicyValidationInput, input: MailPolicyValidationInput)
): MailPolicyValidationMessage[] { : MailPolicyValidationMessage[] {
if (!policy) return []; if (!policy) return [];
const checks: ValueCheck[] = [ const checks: ValueCheck[] = [
{ key: "smtp_hosts", value: input.smtpHost ?? "" }, { key: "smtp_hosts", value: input.smtpHost ?? "" },
{ key: "imap_hosts", value: input.imapHost ?? "" }, { key: "imap_hosts", value: input.imapHost ?? "" },
{ key: "envelope_senders", value: input.envelopeSender ?? "" }, { key: "envelope_senders", value: input.envelopeSender ?? "" },
{ key: "from_headers", value: input.fromHeader ?? "" }, { key: "from_headers", value: input.fromHeader ?? "" },
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))) ...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))).
.map((value) => ({ key: "recipient_domains" as const, value })) map((value) => ({ key: "recipient_domains" as const, value }))];
];
return checks.flatMap(({ key, value }) => { return checks.flatMap(({ key, value }) => {
const result = mailPolicyValueAllowed(policy, key, value); const result = mailPolicyValueAllowed(policy, key, value);
@@ -58,18 +58,18 @@ export function validateMailPolicy(
label, label,
value: result.value, value: result.value,
severity: "danger" as const, severity: "danger" as const,
message: result.reason === "blacklist" message: result.reason === "blacklist" ?
? `${label} ${result.value} is denied by pattern ${result.pattern}.` `${label} ${result.value} is denied by pattern ${result.pattern}.` :
: `${label} ${result.value} is outside the effective allow-list.` `${label} ${result.value} is outside the effective allow-list.`
}]; }];
}); });
} }
export function mailPolicyValueAllowed( export function mailPolicyValueAllowed(
policy: MailProfilePolicy | null | undefined, policy: MailProfilePolicy | null | undefined,
key: MailProfilePatternKey, key: MailProfilePatternKey,
rawValue: string | null | undefined, rawValue: string | null | undefined)
): { allowed: true; value: string } | { allowed: false; value: string; reason: "blacklist" | "whitelist"; pattern?: string } { : {allowed: true;value: string;} | {allowed: false;value: string;reason: "blacklist" | "whitelist";pattern?: string;} {
const value = normalizePolicyValue(key, rawValue); const value = normalizePolicyValue(key, rawValue);
if (!value || !policy) return { allowed: true, value }; if (!value || !policy) return { allowed: true, value };
@@ -95,9 +95,9 @@ export function wildcardPatternMatches(pattern: string, rawValue: string): boole
} }
function patternsFor( function patternsFor(
rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"], rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"],
key: MailProfilePatternKey, key: MailProfilePatternKey)
): string[] { : string[] {
return (rules?.[key] ?? []).map((pattern) => String(pattern).trim()).filter(Boolean); return (rules?.[key] ?? []).map((pattern) => String(pattern).trim()).filter(Boolean);
} }

View File

@@ -0,0 +1,398 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-mail.active.a733b809": "Active",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
"i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4": "Allowed profiles and wildcard rules for this scope.",
"i18n:govoplan-mail.allowed.77c7b490": "Allowed",
"i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179": "An ancestor allow-list limits selectable profiles to",
"i18n:govoplan-mail.archive.2621c6fd": "Archive",
"i18n:govoplan-mail.bcc.8431acad": "Bcc",
"i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d": "because an ancestor policy blocks those definitions.",
"i18n:govoplan-mail.blacklist_value.556334d0": "blacklist.{value0}",
"i18n:govoplan-mail.blacklist.7b2dd04c": "Blacklist",
"i18n:govoplan-mail.blocked.99613c74": "Blocked",
"i18n:govoplan-mail.bytes_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-local settings",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-local settings",
"i18n:govoplan-mail.campaigns": "Campaigns",
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
"i18n:govoplan-mail.cancel.77dfd213": "Cancel",
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether campaigns may use inline SMTP/IMAP settings instead of reusable profiles.",
"i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4": "Controls whether group-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7": "Controls whether user-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.create_mail_profile.4d2f8f9f": "Create mail profile",
"i18n:govoplan-mail.credential_inheritance_is_locked_by_an_ancestor_.1ee5d263": "credential inheritance is locked by an ancestor policy.",
"i18n:govoplan-mail.credential_inheritance.ec8d7191": "Credential inheritance",
"i18n:govoplan-mail.current_mailbox_folder.55e2aea5": "Current mailbox folder",
"i18n:govoplan-mail.date.eb9a4bc1": "Date",
"i18n:govoplan-mail.deactivate_mail_profile.0e0fd0b8": "Deactivate mail profile",
"i18n:govoplan-mail.deactivate_value_campaign_drafts_using_it_will_n.656f1b9e": "Deactivate {value0}? Campaign drafts using it will need another allowed profile before sending.",
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Decides whether lower scopes inherit saved IMAP credentials from a selected profile or must provide local IMAP credentials.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Decides whether lower scopes inherit saved SMTP credentials from a selected profile or must provide local SMTP credentials.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Description",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox",
"i18n:govoplan-mail.drafts.22a31d86": "Drafts",
"i18n:govoplan-mail.edit_mail_profile.95d1af9c": "Edit mail profile",
"i18n:govoplan-mail.edit_value_credentials.1e2dc4f6": "Edit {value0} credentials",
"i18n:govoplan-mail.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-mail.effective_mail_policy_blocks_the_current_profile.1b555820": "Effective mail policy blocks the current profile values.",
"i18n:govoplan-mail.effective_policy.feedb950": "Effective policy",
"i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d": "Effective values are shown in the table rows above.",
"i18n:govoplan-mail.envelope_sender.5ec276a0": "Envelope sender",
"i18n:govoplan-mail.envelope_senders.269065cd": "Envelope senders",
"i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-mail.explicit_allow.6a7946f8": "Explicit allow",
"i18n:govoplan-mail.first_page.49d74b49": "First page",
"i18n:govoplan-mail.flagged.f8db8a17": "Flagged",
"i18n:govoplan-mail.folders.19adc47b": "Folders",
"i18n:govoplan-mail.from_header.bb78e85d": "From header",
"i18n:govoplan-mail.from_headers.b3ea473b": "From headers",
"i18n:govoplan-mail.from.3f66052a": "From",
"i18n:govoplan-mail.generated_from_name.33d69a91": "Generated from name",
"i18n:govoplan-mail.group_profiles.74568838": "Group profiles",
"i18n:govoplan-mail.group.171a0606": "Group",
"i18n:govoplan-mail.groups": "Groups",
"i18n:govoplan-mail.imap_credentials.da847469": "IMAP credentials",
"i18n:govoplan-mail.imap_host.b53c3751": "IMAP host",
"i18n:govoplan-mail.imap_hostnames.ac9c1d78": "IMAP hostnames",
"i18n:govoplan-mail.imap_not_configured.b2892af3": "IMAP not configured",
"i18n:govoplan-mail.imap_profile.5165df81": "IMAP profile",
"i18n:govoplan-mail.imap_server_host_patterns.52b20b83": "IMAP server host patterns.",
"i18n:govoplan-mail.imap.271f9ef2": "IMAP",
"i18n:govoplan-mail.inactive.09af574c": "Inactive",
"i18n:govoplan-mail.inherit_parent_decision.42125bda": "Inherit parent decision",
"i18n:govoplan-mail.inherit_profile_credentials.0034bac0": "Inherit profile credentials",
"i18n:govoplan-mail.inherit.18f99833": "Inherit",
"i18n:govoplan-mail.junk.86c7d94c": "Junk",
"i18n:govoplan-mail.last_page.b01f16ae": "Last page",
"i18n:govoplan-mail.loading_folders.17f9f0e2": "Loading folders...",
"i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8": "Loading mail profile policy...",
"i18n:govoplan-mail.loading_mail_profiles.87de3560": "Loading mail profiles...",
"i18n:govoplan-mail.loading_message.815c2094": "Loading message...",
"i18n:govoplan-mail.loading_messages.4294022c": "Loading messages",
"i18n:govoplan-mail.loading_messages.77b62232": "Loading messages...",
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local",
"i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d": "Lower-level mail definitions",
"i18n:govoplan-mail.lower_levels.940821ee": "Lower levels",
"i18n:govoplan-mail.mail_actions.c08b5f08": "Mail actions",
"i18n:govoplan-mail.mail_profile_policy_saved.666847bf": "Mail profile policy saved.",
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
"i18n:govoplan-mail.mailbox_messages.5c06afaf": "Mailbox messages",
"i18n:govoplan-mail.message_preview.58de1450": "Message preview",
"i18n:govoplan-mail.messages.f1702b46": "Messages",
"i18n:govoplan-mail.name.709a2322": "Name",
"i18n:govoplan-mail.new_profile.ca36da25": "New profile",
"i18n:govoplan-mail.next_page.4bfc194b": "Next page",
"i18n:govoplan-mail.no_explicit_intersection.f2d62c71": "No explicit intersection",
"i18n:govoplan-mail.no_folder_loaded.889bdb1b": "No folder loaded",
"i18n:govoplan-mail.no_folders_available.14133b26": "No folders available.",
"i18n:govoplan-mail.no_host.4c710d7d": "No host",
"i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8": "No IMAP-enabled mail profiles.",
"i18n:govoplan-mail.no_imap_profile_selected.e7d1516f": "No IMAP profile selected",
"i18n:govoplan-mail.no_imap_profiles_available.d64589f8": "No IMAP profiles available",
"i18n:govoplan-mail.no_value_available": "No {value0} available.",
"i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39": "No local profile allow-list is set.",
"i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d": "No messages in this folder.",
"i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916": "No messages match the current filter on this page.",
"i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85": "No profiles are visible for this policy scope.",
"i18n:govoplan-mail.no_profiles_in_this_scope.302b21d8": "No profiles in this scope.",
"i18n:govoplan-mail.no_saved_password.32ce2b16": "No saved password",
"i18n:govoplan-mail.no_subject.49b20da0": "(no subject)",
"i18n:govoplan-mail.no_username.1c182624": "No username",
"i18n:govoplan-mail.not_configured.67f2141f": "not configured",
"i18n:govoplan-mail.not_configured.811931bb": "Not configured",
"i18n:govoplan-mail.on_page.ca7166f4": "on page",
"i18n:govoplan-mail.owner_policy.1e8df143": "Owner policy",
"i18n:govoplan-mail.page.fb06270f": "Page",
"i18n:govoplan-mail.password_saved.f6fab237": "Password saved",
"i18n:govoplan-mail.password.8be3c943": "Password",
"i18n:govoplan-mail.policy_path.1ba91ee5": "Policy path",
"i18n:govoplan-mail.policy_target.a19dcee9": "Policy target",
"i18n:govoplan-mail.policy.bb9cf141": "Policy",
"i18n:govoplan-mail.preview.f1fbb2b4": "Preview",
"i18n:govoplan-mail.previous_page.81f54719": "Previous page",
"i18n:govoplan-mail.profile_allow_list.507dfe6c": "Profile allow-list",
"i18n:govoplan-mail.profile_inherited.ea25d2e1": "Profile inherited",
"i18n:govoplan-mail.profile_s.742e9200": "profile(s).",
"i18n:govoplan-mail.profile_value_created.2a088d8d": "Profile {value0} created.",
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
"i18n:govoplan-mail.refresh_mailbox_folders.d9af9963": "Refresh mailbox folders",
"i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c": "Refresh messages in the current folder",
"i18n:govoplan-mail.reload_imap_profiles.b04c11c8": "Reload IMAP profiles",
"i18n:govoplan-mail.reload.cce71553": "Reload",
"i18n:govoplan-mail.require_local_credentials.da7b1d7c": "Require local credentials",
"i18n:govoplan-mail.rows_per_page.af2f9c1b": "Rows per page",
"i18n:govoplan-mail.save_policy.77d67ce3": "Save policy",
"i18n:govoplan-mail.save_profile.f597c0e8": "Save profile",
"i18n:govoplan-mail.saved.c0ae8f6e": "Saved",
"i18n:govoplan-mail.saving.56a2285c": "Saving…",
"i18n:govoplan-mail.search_messages.abea65ae": "Search messages",
"i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342": "Select a message to inspect its content.",
"i18n:govoplan-mail.select_an_imap_profile.5445648c": "Select an IMAP profile.",
"i18n:govoplan-mail.sent.35f49dcf": "Sent",
"i18n:govoplan-mail.server_credential.7fb9a24e": "Server / credential",
"i18n:govoplan-mail.size.b7152342": "Size",
"i18n:govoplan-mail.slug.094da9b9": "Slug",
"i18n:govoplan-mail.smtp_credentials.f73ef315": "SMTP credentials",
"i18n:govoplan-mail.smtp_envelope_sender_patterns.8c1fd95e": "SMTP envelope sender patterns.",
"i18n:govoplan-mail.smtp_host.2d4a434b": "SMTP host",
"i18n:govoplan-mail.smtp_hostnames.36eb51d8": "SMTP hostnames",
"i18n:govoplan-mail.smtp_server_host_patterns.cf6120c3": "SMTP server host patterns.",
"i18n:govoplan-mail.smtp.efff9cca": "SMTP",
"i18n:govoplan-mail.status.bae7d5be": "Status",
"i18n:govoplan-mail.subject.8d183dbd": "Subject",
"i18n:govoplan-mail.system_allow.ed6744b1": "System: Allow",
"i18n:govoplan-mail.system_profile_inherited.f4b8b5ce": "System: Profile inherited",
"i18n:govoplan-mail.system.bc0792d8": "System",
"i18n:govoplan-mail.target.61ad50a9": "Target",
"i18n:govoplan-mail.tenant.3ca93c78": "Tenant",
"i18n:govoplan-mail.test_imap.ef1bd79c": "Test IMAP",
"i18n:govoplan-mail.test_saved_imap.923dbe4a": "Test saved IMAP",
"i18n:govoplan-mail.test_saved_smtp.008d8054": "Test saved SMTP",
"i18n:govoplan-mail.test_smtp.e5697981": "Test SMTP",
"i18n:govoplan-mail.this_profile.5fd9cdf1": "this profile",
"i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Users",
"i18n:govoplan-mail.value_message": "{value0} message",
"i18n:govoplan-mail.value_messages": "{value0} messages",
"i18n:govoplan-mail.value_of_value_messages.981a1618": "{value0} of {value1} messages",
"i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44": "{value0} profile(s) allowed by this scope.",
"i18n:govoplan-mail.value_profile_s.d6b9d0af": "{value0} profile(s)",
"i18n:govoplan-mail.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-mail.value.48afe802": "· {value0}",
"i18n:govoplan-mail.value.8dce170d": "Value",
"i18n:govoplan-mail.value_scope": "{value0} scope",
"i18n:govoplan-mail.visible_from_header_patterns.ea77d99d": "Visible From header patterns.",
"i18n:govoplan-mail.whitelist_value.d4cc3755": "whitelist.{value0}",
"i18n:govoplan-mail.whitelist.53c2ad30": "Whitelist",
"i18n:govoplan-mail.wildcard_rules.54fb3fc0": "Wildcard rules",
"i18n:govoplan-mail.working.049ac820": "Working..."
},
"de": {
"i18n:govoplan-mail.active.a733b809": "Aktiv",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
"i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4": "Allowed profiles and wildcard rules for this scope.",
"i18n:govoplan-mail.allowed.77c7b490": "Allowed",
"i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179": "An ancestor allow-list limits selectable profiles to",
"i18n:govoplan-mail.archive.2621c6fd": "Archive",
"i18n:govoplan-mail.bcc.8431acad": "Bcc",
"i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d": "because an ancestor policy blocks those definitions.",
"i18n:govoplan-mail.blacklist_value.556334d0": "blacklist.{value0}",
"i18n:govoplan-mail.blacklist.7b2dd04c": "Blacklist",
"i18n:govoplan-mail.blocked.99613c74": "Blocked",
"i18n:govoplan-mail.bytes_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-local settings",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-local settings",
"i18n:govoplan-mail.campaigns": "Kampagnen",
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
"i18n:govoplan-mail.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether campaigns may use inline SMTP/IMAP settings instead of reusable profiles.",
"i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4": "Controls whether group-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7": "Controls whether user-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.create_mail_profile.4d2f8f9f": "Create mail profile",
"i18n:govoplan-mail.credential_inheritance_is_locked_by_an_ancestor_.1ee5d263": "credential inheritance is locked by an ancestor policy.",
"i18n:govoplan-mail.credential_inheritance.ec8d7191": "Credential inheritance",
"i18n:govoplan-mail.current_mailbox_folder.55e2aea5": "Current mailbox folder",
"i18n:govoplan-mail.date.eb9a4bc1": "Date",
"i18n:govoplan-mail.deactivate_mail_profile.0e0fd0b8": "Deactivate mail profile",
"i18n:govoplan-mail.deactivate_value_campaign_drafts_using_it_will_n.656f1b9e": "Deactivate {value0}? Campaign drafts using it will need another allowed profile before sending.",
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Decides whether lower scopes inherit saved IMAP credentials from a selected profile or must provide local IMAP credentials.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Decides whether lower scopes inherit saved SMTP credentials from a selected profile or must provide local SMTP credentials.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",
"i18n:govoplan-mail.drafts.22a31d86": "Drafts",
"i18n:govoplan-mail.edit_mail_profile.95d1af9c": "Edit mail profile",
"i18n:govoplan-mail.edit_value_credentials.1e2dc4f6": "Edit {value0} credentials",
"i18n:govoplan-mail.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-mail.effective_mail_policy_blocks_the_current_profile.1b555820": "Effective mail policy blocks the current profile values.",
"i18n:govoplan-mail.effective_policy.feedb950": "Effective policy",
"i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d": "Effective values are shown in the table rows above.",
"i18n:govoplan-mail.envelope_sender.5ec276a0": "Envelope sender",
"i18n:govoplan-mail.envelope_senders.269065cd": "Envelope senders",
"i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-mail.explicit_allow.6a7946f8": "Explicit allow",
"i18n:govoplan-mail.first_page.49d74b49": "Erste Seite",
"i18n:govoplan-mail.flagged.f8db8a17": "Flagged",
"i18n:govoplan-mail.folders.19adc47b": "Ordner",
"i18n:govoplan-mail.from_header.bb78e85d": "From header",
"i18n:govoplan-mail.from_headers.b3ea473b": "From headers",
"i18n:govoplan-mail.from.3f66052a": "From",
"i18n:govoplan-mail.generated_from_name.33d69a91": "Generated from name",
"i18n:govoplan-mail.group_profiles.74568838": "Group profiles",
"i18n:govoplan-mail.group.171a0606": "Group",
"i18n:govoplan-mail.groups": "Gruppen",
"i18n:govoplan-mail.imap_credentials.da847469": "IMAP credentials",
"i18n:govoplan-mail.imap_host.b53c3751": "IMAP host",
"i18n:govoplan-mail.imap_hostnames.ac9c1d78": "IMAP hostnames",
"i18n:govoplan-mail.imap_not_configured.b2892af3": "IMAP not configured",
"i18n:govoplan-mail.imap_profile.5165df81": "IMAP profile",
"i18n:govoplan-mail.imap_server_host_patterns.52b20b83": "IMAP server host patterns.",
"i18n:govoplan-mail.imap.271f9ef2": "IMAP",
"i18n:govoplan-mail.inactive.09af574c": "Inaktiv",
"i18n:govoplan-mail.inherit_parent_decision.42125bda": "Inherit parent decision",
"i18n:govoplan-mail.inherit_profile_credentials.0034bac0": "Inherit profile credentials",
"i18n:govoplan-mail.inherit.18f99833": "Inherit",
"i18n:govoplan-mail.junk.86c7d94c": "Junk",
"i18n:govoplan-mail.last_page.b01f16ae": "Last page",
"i18n:govoplan-mail.loading_folders.17f9f0e2": "Loading folders...",
"i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8": "Mailprofil-Richtlinie wird geladen...",
"i18n:govoplan-mail.loading_mail_profiles.87de3560": "Loading mail profiles...",
"i18n:govoplan-mail.loading_message.815c2094": "Loading message...",
"i18n:govoplan-mail.loading_messages.4294022c": "Loading messages",
"i18n:govoplan-mail.loading_messages.77b62232": "Loading messages...",
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local",
"i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d": "Lower-level mail definitions",
"i18n:govoplan-mail.lower_levels.940821ee": "Lower levels",
"i18n:govoplan-mail.mail_actions.c08b5f08": "Mail actions",
"i18n:govoplan-mail.mail_profile_policy_saved.666847bf": "Mail profile policy saved.",
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
"i18n:govoplan-mail.mailbox_messages.5c06afaf": "Mailbox messages",
"i18n:govoplan-mail.message_preview.58de1450": "Nachrichtenvorschau",
"i18n:govoplan-mail.messages.f1702b46": "Nachrichten",
"i18n:govoplan-mail.name.709a2322": "Name",
"i18n:govoplan-mail.new_profile.ca36da25": "New profile",
"i18n:govoplan-mail.next_page.4bfc194b": "Nächste Seite",
"i18n:govoplan-mail.no_explicit_intersection.f2d62c71": "No explicit intersection",
"i18n:govoplan-mail.no_folder_loaded.889bdb1b": "No folder loaded",
"i18n:govoplan-mail.no_folders_available.14133b26": "No folders available.",
"i18n:govoplan-mail.no_host.4c710d7d": "No host",
"i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8": "No IMAP-enabled mail profiles.",
"i18n:govoplan-mail.no_imap_profile_selected.e7d1516f": "No IMAP profile selected",
"i18n:govoplan-mail.no_imap_profiles_available.d64589f8": "No IMAP profiles available",
"i18n:govoplan-mail.no_value_available": "Keine {value0} verfügbar.",
"i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39": "No local profile allow-list is set.",
"i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d": "No messages in this folder.",
"i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916": "No messages match the current filter on this page.",
"i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85": "No profiles are visible for this policy scope.",
"i18n:govoplan-mail.no_profiles_in_this_scope.302b21d8": "No profiles in this scope.",
"i18n:govoplan-mail.no_saved_password.32ce2b16": "No saved password",
"i18n:govoplan-mail.no_subject.49b20da0": "(no subject)",
"i18n:govoplan-mail.no_username.1c182624": "No username",
"i18n:govoplan-mail.not_configured.67f2141f": "not configured",
"i18n:govoplan-mail.not_configured.811931bb": "Nicht konfiguriert",
"i18n:govoplan-mail.on_page.ca7166f4": "on page",
"i18n:govoplan-mail.owner_policy.1e8df143": "Owner policy",
"i18n:govoplan-mail.page.fb06270f": "Seite",
"i18n:govoplan-mail.password_saved.f6fab237": "Password saved",
"i18n:govoplan-mail.password.8be3c943": "Passwort",
"i18n:govoplan-mail.policy_path.1ba91ee5": "Policy path",
"i18n:govoplan-mail.policy_target.a19dcee9": "Policy target",
"i18n:govoplan-mail.policy.bb9cf141": "Richtlinie",
"i18n:govoplan-mail.preview.f1fbb2b4": "Vorschau",
"i18n:govoplan-mail.previous_page.81f54719": "Vorherige Seite",
"i18n:govoplan-mail.profile_allow_list.507dfe6c": "Profile allow-list",
"i18n:govoplan-mail.profile_inherited.ea25d2e1": "Profile inherited",
"i18n:govoplan-mail.profile_s.742e9200": "profile(s).",
"i18n:govoplan-mail.profile_value_created.2a088d8d": "Profile {value0} created.",
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
"i18n:govoplan-mail.refresh_mailbox_folders.d9af9963": "Refresh mailbox folders",
"i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c": "Refresh messages in the current folder",
"i18n:govoplan-mail.reload_imap_profiles.b04c11c8": "Reload IMAP profiles",
"i18n:govoplan-mail.reload.cce71553": "Neu laden",
"i18n:govoplan-mail.require_local_credentials.da7b1d7c": "Require local credentials",
"i18n:govoplan-mail.rows_per_page.af2f9c1b": "Zeilen pro Seite",
"i18n:govoplan-mail.save_policy.77d67ce3": "Save policy",
"i18n:govoplan-mail.save_profile.f597c0e8": "Save profile",
"i18n:govoplan-mail.saved.c0ae8f6e": "Gespeichert",
"i18n:govoplan-mail.saving.56a2285c": "Saving…",
"i18n:govoplan-mail.search_messages.abea65ae": "Search messages",
"i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342": "Select a message to inspect its content.",
"i18n:govoplan-mail.select_an_imap_profile.5445648c": "Select an IMAP profile.",
"i18n:govoplan-mail.sent.35f49dcf": "Sent",
"i18n:govoplan-mail.server_credential.7fb9a24e": "Server / credential",
"i18n:govoplan-mail.size.b7152342": "Größe",
"i18n:govoplan-mail.slug.094da9b9": "Slug",
"i18n:govoplan-mail.smtp_credentials.f73ef315": "SMTP credentials",
"i18n:govoplan-mail.smtp_envelope_sender_patterns.8c1fd95e": "SMTP envelope sender patterns.",
"i18n:govoplan-mail.smtp_host.2d4a434b": "SMTP host",
"i18n:govoplan-mail.smtp_hostnames.36eb51d8": "SMTP hostnames",
"i18n:govoplan-mail.smtp_server_host_patterns.cf6120c3": "SMTP server host patterns.",
"i18n:govoplan-mail.smtp.efff9cca": "SMTP",
"i18n:govoplan-mail.status.bae7d5be": "Status",
"i18n:govoplan-mail.subject.8d183dbd": "Betreff",
"i18n:govoplan-mail.system_allow.ed6744b1": "System: Allow",
"i18n:govoplan-mail.system_profile_inherited.f4b8b5ce": "System: Profile inherited",
"i18n:govoplan-mail.system.bc0792d8": "System",
"i18n:govoplan-mail.target.61ad50a9": "Target",
"i18n:govoplan-mail.tenant.3ca93c78": "Tenant",
"i18n:govoplan-mail.test_imap.ef1bd79c": "Test IMAP",
"i18n:govoplan-mail.test_saved_imap.923dbe4a": "Test saved IMAP",
"i18n:govoplan-mail.test_saved_smtp.008d8054": "Test saved SMTP",
"i18n:govoplan-mail.test_smtp.e5697981": "Test SMTP",
"i18n:govoplan-mail.this_profile.5fd9cdf1": "this profile",
"i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Benutzer",
"i18n:govoplan-mail.value_message": "{value0} message",
"i18n:govoplan-mail.value_messages": "{value0} messages",
"i18n:govoplan-mail.value_of_value_messages.981a1618": "{value0} of {value1} messages",
"i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44": "{value0} profile(s) allowed by this scope.",
"i18n:govoplan-mail.value_profile_s.d6b9d0af": "{value0} profile(s)",
"i18n:govoplan-mail.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-mail.value.48afe802": "· {value0}",
"i18n:govoplan-mail.value.8dce170d": "Value",
"i18n:govoplan-mail.value_scope": "Geltungsbereich: {value0}",
"i18n:govoplan-mail.visible_from_header_patterns.ea77d99d": "Visible From header patterns.",
"i18n:govoplan-mail.whitelist_value.d4cc3755": "whitelist.{value0}",
"i18n:govoplan-mail.whitelist.53c2ad30": "Whitelist",
"i18n:govoplan-mail.wildcard_rules.54fb3fc0": "Wildcard rules",
"i18n:govoplan-mail.working.049ac820": "Working..."
}
};

View File

@@ -1,4 +1,3 @@
import "./styles/mail-profiles.css";
export { default } from "./module"; export { default } from "./module";
export * from "./module"; export * from "./module";
export * from "./api/mail"; export * from "./api/mail";

View File

@@ -2,24 +2,31 @@ import { createElement, lazy } from "react";
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui"; import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement"; import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
import { validateMailPolicy } from "./features/mail/mailPolicyValidation"; import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/mail-profiles.css";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage")); const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"]; const mailboxRead = ["mail:mailbox:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const mailModule: PlatformWebModule = { export const mailModule: PlatformWebModule = {
id: "mail", id: "mail",
label: "Mail", label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], dependencies: ["access"],
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }], translations,
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [ routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) } { path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
],
uiCapabilities: { uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability "mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
}, },
runtimeUiCapabilities: { runtimeUiCapabilities: {
"mail.devMailbox": { enabled: true, label: "Development mock mailbox" } satisfies MailDevMailboxUiCapability "mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
} }
}; };

View File

@@ -18,30 +18,6 @@
max-width: 520px; max-width: 520px;
} }
.mail-profile-table-surface {
width: 100%;
max-width: 100%;
min-width: 0;
}
.mail-profile-table-surface > .data-grid-shell {
width: 100%;
max-width: 100%;
min-width: 0;
}
.card-body > .mail-profile-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .mail-profile-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.mail-profile-dialog .dialog-body { .mail-profile-dialog .dialog-body {
max-height: min(76vh, 820px); max-height: min(76vh, 820px);
overflow: auto; overflow: auto;
@@ -53,7 +29,6 @@
} }
.mail-profile-form h3, .mail-profile-form h3,
.mail-policy-section h3,
.mail-policy-pattern-grid h4 { .mail-policy-pattern-grid h4 {
margin: 0; margin: 0;
color: var(--text-strong); color: var(--text-strong);
@@ -83,12 +58,6 @@
margin: 0; margin: 0;
} }
.mail-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.mail-profile-checkbox-grid { .mail-profile-checkbox-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));