chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:22 +02:00
parent b4b0c76455
commit d5d5b89603
21 changed files with 1955 additions and 551 deletions

View File

@@ -60,6 +60,11 @@ Development mailbox routes are registered by the mail module only when the
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
does not contribute these routes directly.
POP3 and JMAP are deferred. The protocol decision is documented in
[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize
SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
only for explicit legacy-download requirements.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
## Release packaging

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

@@ -20,7 +20,7 @@
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"

View File

@@ -21,7 +21,7 @@ class MailServerProfile(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -34,8 +34,8 @@ class MailServerProfile(Base, TimestampMixin):
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
imap_username: Mapped[str | None] = mapped_column(String(320))
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
tenant: Mapped["Tenant"] = relationship()
@@ -48,7 +48,7 @@ class MailProfilePolicy(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)

View File

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

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

@@ -17,6 +17,7 @@ from govoplan_core.core.campaigns import (
CampaignMailPolicyContext,
CampaignMailPolicyContextProvider,
)
from govoplan_core.core.policy import policy_source_step
from govoplan_core.core.runtime import get_registry
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
@@ -280,12 +281,12 @@ def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: s
def _legacy_tenant_settings(session: Session, tenant_id: str) -> dict[str, Any] | None:
row = session.execute(text("select settings from tenants where id = :tenant_id"), {"tenant_id": tenant_id}).first()
row = session.execute(text("select settings from tenancy_tenants where id = :tenant_id"), {"tenant_id": tenant_id}).first()
return _json_object(row[0]) if row else None
def _legacy_subject_policy(session: Session, *, table_name: str, tenant_id: str, scope_id: str) -> dict[str, Any] | None:
if table_name not in {"users", "groups"}:
if table_name not in {"access_users", "access_groups"}:
raise MailProfileError("Unsupported mail policy subject")
row = session.execute(
text(f"select mail_profile_policy from {table_name} where id = :scope_id and tenant_id = :tenant_id"),
@@ -303,7 +304,7 @@ def _user_exists(session: Session, *, tenant_id: str, user_id: str) -> bool:
if directory is not None:
user = directory.get_user(user_id)
return bool(user and user.tenant_id == tenant_id and user.status == "active")
return _legacy_subject_policy(session, table_name="users", tenant_id=tenant_id, scope_id=user_id) is not None
return _legacy_subject_policy(session, table_name="access_users", tenant_id=tenant_id, scope_id=user_id) is not None
def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool:
@@ -311,7 +312,7 @@ def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool:
if directory is not None:
group = directory.get_group(group_id)
return bool(group and group.tenant_id == tenant_id and group.status == "active")
return _legacy_subject_policy(session, table_name="groups", tenant_id=tenant_id, scope_id=group_id) is not None
return _legacy_subject_policy(session, table_name="access_groups", tenant_id=tenant_id, scope_id=group_id) is not None
def _policy_scope_key(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str | None, str, str | None]:
@@ -355,10 +356,10 @@ def _legacy_mail_profile_policy(
if not scope_id:
return None
if clean_scope == "user":
legacy = _legacy_subject_policy(session, table_name="users", tenant_id=tenant_id, scope_id=scope_id)
legacy = _legacy_subject_policy(session, table_name="access_users", tenant_id=tenant_id, scope_id=scope_id)
return _policy_from_attr(legacy) if legacy is not None else None
if clean_scope == "group":
legacy = _legacy_subject_policy(session, table_name="groups", tenant_id=tenant_id, scope_id=scope_id)
legacy = _legacy_subject_policy(session, table_name="access_groups", tenant_id=tenant_id, scope_id=scope_id)
return _policy_from_attr(legacy) if legacy is not None else None
try:
context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
@@ -463,13 +464,13 @@ def _mail_policy_source_policy(policy: dict[str, Any], *, baseline: bool = False
def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
return {
"scope_type": source,
"scope_id": source_id,
"label": label,
"applied_fields": _mail_policy_source_fields(policy, baseline=baseline),
"policy": _mail_policy_source_policy(policy, baseline=baseline),
}
return policy_source_step(
source,
label,
source_id,
applied_fields=_mail_policy_source_fields(policy, baseline=baseline),
policy=_mail_policy_source_policy(policy, baseline=baseline),
)
def _merge_credential_policy(
@@ -1495,7 +1496,7 @@ def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> lis
if directory is not None:
return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
rows = session.execute(
text("select group_id from user_group_memberships where tenant_id = :tenant_id and user_id = :user_id"),
text("select group_id from access_user_group_memberships where tenant_id = :tenant_id and user_id = :user_id"),
{"tenant_id": tenant_id, "user_id": user_id},
).all()
return [row[0] for row in rows]

View File

@@ -3,8 +3,20 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_mail.backend.documentation import documentation_topics
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
@@ -108,6 +120,35 @@ manifest = ModuleManifest(
capability_factories={
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
},
documentation=(
DocumentationTopic(
id="mail.profiles-and-policy",
title="Mail profiles and policy hierarchy",
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.",
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define their own mail-server settings. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
order=39,
i18n_key="mail.topic.profiles_and_policy",
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:profile:read", "admin:policies:read", "system:settings:read"),
configuration_keys=("mail_profile_policy",),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/api/v1/mail/profiles", kind="api"),
DocumentationLink(label="Tenant mail policy", href="/api/v1/mail/policies/tenant", kind="api"),
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
configuration_keys=("mail_profile_policy",),
),
),
documentation_providers=(documentation_topics,),
)

View File

@@ -1,7 +1,7 @@
"""mail profile policies
Revision ID: 3d4e5f708192
Revises: 1b2c3d4e5f70
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-06 00:00:00.000000
"""
from __future__ import annotations
@@ -11,7 +11,7 @@ import sqlalchemy as sa
revision = "3d4e5f708192"
down_revision = "1b2c3d4e5f70"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
@@ -29,7 +29,7 @@ def upgrade() -> None:
sa.Column("policy", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_mail_profile_policies_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_mail_profile_policies_tenant_id_tenants"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_profile_policies")),
sa.UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"),
)

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import (
MailConnectionTestResponse,
@@ -14,13 +16,23 @@ from govoplan_mail.backend.schemas import (
MailMailboxMessageSummaryResponse,
MailProfilePolicyResponse,
MailProfilePolicyUpdateRequest,
MailSettingsDeltaResponse,
MailServerProfileCreateRequest,
MailServerProfileListResponse,
MailServerProfileResponse,
MailServerProfileUpdateRequest,
MailSmtpTestRequest,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
decode_sequence_watermark,
encode_sequence_watermark,
record_change,
sequence_watermark_is_expired,
)
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.db.session import get_session
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
@@ -39,10 +51,18 @@ from govoplan_mail.backend.mail_profiles import (
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.sending.smtp import test_smtp_login
from sqlalchemy.orm import Session
router = APIRouter(prefix="/mail", tags=["mail"])
MAIL_MODULE_ID = "mail"
MAIL_PROFILES_COLLECTION = "mail.profiles"
MAIL_POLICIES_COLLECTION = "mail.profile_policies"
MAIL_SETTINGS_COLLECTIONS = (MAIL_PROFILES_COLLECTION, MAIL_POLICIES_COLLECTION)
MAIL_PROFILE_RESOURCE = "mail_profile"
MAIL_POLICY_RESOURCE = "mail_profile_policy"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
@@ -95,6 +115,122 @@ def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
def _policy_response(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
campaign_id: str | None = None,
) -> MailProfilePolicyResponse:
policy = get_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
effective_policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(
scope_type=scope_type,
scope_id=scope_id,
policy=policy,
effective_policy=effective,
parent_policy=parent,
effective_policy_sources=effective_sources,
parent_policy_sources=parent_sources,
)
def _record_mail_change(
session: Session,
*,
collection: str,
resource_type: str,
resource_id: str | None,
operation: str,
principal: ApiPrincipal,
tenant_id: str | None,
payload: dict[str, object] | None = None,
) -> None:
if not resource_id:
return
record_change(
session,
module_id=MAIL_MODULE_ID,
collection=collection,
resource_type=resource_type,
resource_id=resource_id,
operation=operation,
tenant_id=tenant_id,
actor_type="user",
actor_id=principal.user.id,
payload=payload or {},
)
def _mail_delta_query(session: Session, *, tenant_id: str, since_sequence: int):
return session.query(ChangeSequenceEntry).filter(
ChangeSequenceEntry.id > since_sequence,
ChangeSequenceEntry.module_id == MAIL_MODULE_ID,
ChangeSequenceEntry.collection.in_(MAIL_SETTINGS_COLLECTIONS),
or_(ChangeSequenceEntry.tenant_id == tenant_id, ChangeSequenceEntry.tenant_id.is_(None)),
)
def _mail_settings_watermark(session: Session, *, tenant_id: str) -> str:
sequence_id = _mail_delta_query(session, tenant_id=tenant_id, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar()
return encode_sequence_watermark(int(sequence_id or 0))
def _mail_settings_entries(session: Session, *, tenant_id: str, since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
expired_for_tenant = sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=tenant_id,
module_id=MAIL_MODULE_ID,
collections=MAIL_SETTINGS_COLLECTIONS,
)
expired_for_system = sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=None,
module_id=MAIL_MODULE_ID,
collections=MAIL_SETTINGS_COLLECTIONS,
)
if expired_for_tenant or expired_for_system:
return None, False
entries_plus_one = _mail_delta_query(
session,
tenant_id=tenant_id,
since_sequence=since_sequence,
).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all()
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _mail_settings_response_watermark(session: Session, *, tenant_id: str, entries, has_more: bool) -> str:
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _mail_settings_watermark(session, tenant_id=tenant_id)
def _mail_deleted_item(entry) -> DeltaDeletedItem:
return DeltaDeletedItem(
id=entry.resource_id,
resource_type=entry.resource_type,
revision=encode_sequence_watermark(entry.id),
deleted_at=entry.created_at if entry.operation == "deleted" else None,
)
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
return MailMailboxMessageSummaryResponse(
uid=message.uid,
@@ -132,6 +268,76 @@ def _mailbox_detail_response(message) -> MailMailboxMessageDetailResponse:
)
def _mailbox_cursor_fingerprint(*, tenant_id: str, profile_id: str, folder: str, limit: int) -> str:
return keyset_query_fingerprint(
MAILBOX_MESSAGES_CURSOR_SCOPE,
{"tenant_id": tenant_id, "profile_id": profile_id, "folder": folder, "limit": limit, "sort": "imap.uid.desc"},
)
def _mailbox_cursor_values(cursor: str | None, *, fingerprint: str | None = None) -> dict[str, object] | None:
try:
return decode_keyset_cursor(MAILBOX_MESSAGES_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
def _mailbox_cursor_limit(cursor: str | None, explicit_limit: int | None) -> int:
if explicit_limit is not None:
return explicit_limit
values = _mailbox_cursor_values(cursor) if cursor else None
raw_limit = values.get("limit") if values else DEFAULT_MAILBOX_MESSAGE_LIMIT
if not isinstance(raw_limit, int) or raw_limit < 1 or raw_limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
return raw_limit
def _mailbox_cursor_position(cursor: str | None, *, fingerprint: str, offset: int) -> tuple[int, str | None, str | None, dict[str, object] | None]:
values = _mailbox_cursor_values(cursor, fingerprint=fingerprint) if cursor else None
if values is None:
return offset, None, None, None
raw_offset = values.get("offset")
if not isinstance(raw_offset, int) or raw_offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
after_uid = values.get("after_uid") or values.get("anchor_uid")
uidvalidity = values.get("uidvalidity")
if not isinstance(after_uid, str) or not after_uid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
if uidvalidity is not None and not isinstance(uidvalidity, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
return raw_offset, after_uid, uidvalidity, values
def _next_mailbox_cursor(
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int,
total_count: int,
uidvalidity: str | None,
messages,
) -> tuple[str | None, bool]:
cursor_stable = bool(uidvalidity)
next_offset = offset + len(messages)
if not cursor_stable or next_offset >= total_count or not messages:
return None, cursor_stable
return (
encode_keyset_cursor(
MAILBOX_MESSAGES_CURSOR_SCOPE,
fingerprint=_mailbox_cursor_fingerprint(tenant_id=tenant_id, profile_id=profile_id, folder=folder, limit=limit),
values={
"offset": next_offset,
"limit": limit,
"uidvalidity": uidvalidity,
"after_uid": messages[-1].uid,
},
),
cursor_stable,
)
def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str):
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
imap = imap_config_from_profile(profile)
@@ -146,6 +352,104 @@ def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, sco
return parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id).as_dict()
def _full_mail_settings_delta_response(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
include_inactive: bool,
campaign_id: str | None,
) -> MailSettingsDeltaResponse:
profiles = list_mail_server_profiles(
session,
tenant_id=tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
return MailSettingsDeltaResponse(
profiles=[_profile_response(profile) for profile in profiles],
policy=_policy_response(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id),
changed_sections=["profiles", "policy"],
deleted=[],
watermark=_mail_settings_watermark(session, tenant_id=tenant_id),
has_more=False,
full=True,
)
@router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
def mail_settings_delta(
scope_type: str = Query(default="tenant"),
scope_id: str | None = Query(default=None),
include_inactive: bool = False,
campaign_id: str | None = Query(default=None),
since: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
clean_scope_type = scope_type.strip().casefold()
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
_require_policy_read_scope(principal, clean_scope_type)
if since is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
entries, has_more = _mail_settings_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit)
if entries is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
changed_profile_ids = {
entry.resource_id
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION and entry.resource_type == MAIL_PROFILE_RESOURCE
}
visible_profiles = {
profile.id: profile
for profile in list_mail_server_profiles(
session,
tenant_id=principal.tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
if profile.id in changed_profile_ids
}
policy_changed = any(entry.collection == MAIL_POLICIES_COLLECTION for entry in entries)
changed_sections = []
if changed_profile_ids:
changed_sections.append("profiles")
if policy_changed:
changed_sections.append("policy")
deleted = [
_mail_deleted_item(entry)
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION
and entry.resource_type == MAIL_PROFILE_RESOURCE
and entry.resource_id not in visible_profiles
]
return MailSettingsDeltaResponse(
profiles=[_profile_response(profile) for profile in visible_profiles.values()],
policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope_type, scope_id=scope_id, campaign_id=campaign_id) if policy_changed else None,
changed_sections=changed_sections,
deleted=deleted,
watermark=_mail_settings_response_watermark(session, tenant_id=principal.tenant_id, entries=entries, has_more=has_more),
has_more=has_more,
full=False,
)
@router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles(
include_inactive: bool = False,
@@ -189,12 +493,22 @@ def create_profile(
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="created",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
@@ -245,12 +559,22 @@ def update_profile(
imap=imap_config,
clear_imap=payload.clear_imap,
)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="updated",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
@@ -264,6 +588,16 @@ def deactivate_profile(
_require_profile_write_scope(principal, profile.scope_type or "tenant")
profile.is_active = False
session.add(profile)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="deleted",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
@@ -283,20 +617,7 @@ def read_mail_profile_policy(
scope_type = scope_type.strip().casefold()
_require_policy_read_scope(principal, scope_type)
try:
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
effective_policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -319,22 +640,21 @@ def write_mail_profile_policy(
scope_id=scope_id,
policy=payload.policy.model_dump(mode="json"),
)
session.commit()
effective_policy = effective_mail_profile_policy_for_scope(
_record_mail_change(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
collection=MAIL_POLICIES_COLLECTION,
resource_type=MAIL_POLICY_RESOURCE,
resource_id=f"{scope_type}:{scope_id or ''}",
operation="updated",
principal=principal,
tenant_id=None if scope_type == "system" else principal.tenant_id,
payload={"scope_type": scope_type, "scope_id": scope_id},
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
session.commit()
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
@@ -414,7 +734,7 @@ def list_profile_mailbox_folders(
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -423,15 +743,37 @@ def list_profile_mailbox_folders(
def list_profile_mailbox_messages(
profile_id: str,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int = Query(default=50, ge=1, le=100),
limit: int | None = Query(default=None, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
cursor: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset)
effective_limit = _mailbox_cursor_limit(cursor, limit)
fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit)
effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset)
result = list_imap_messages(
imap_config=imap,
folder=folder,
limit=effective_limit,
offset=effective_offset,
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
full = bool(cursor_values is not None and result.cursor_reset)
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=result.folder,
limit=result.limit,
offset=result.offset,
total_count=result.total_count,
uidvalidity=result.uidvalidity,
messages=result.messages,
)
return MailMailboxMessageListResponse(
profile_id=profile_id,
folder=result.folder,
@@ -441,12 +783,16 @@ def list_profile_mailbox_messages(
total_count=result.total_count,
offset=result.offset,
limit=result.limit,
cursor=cursor,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=full or not cursor_stable,
messages=[_mailbox_summary_response(message) for message in result.messages],
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -474,7 +820,7 @@ def get_profile_mailbox_message(
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).casefold() else status.HTTP_502_BAD_GATEWAY
raise HTTPException(status_code=status_code, detail=str(exc)) from exc

View File

@@ -5,6 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials
@@ -93,6 +94,7 @@ class MailProfilePolicyUpdateRequest(BaseModel):
class PolicySourceStepResponse(BaseModel):
scope_type: str
scope_id: str | None = None
path: str
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
@@ -201,6 +203,16 @@ class MailServerProfileListResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
class MailSettingsDeltaResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
policy: MailProfilePolicyResponse | None = None
changed_sections: list[str] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
@@ -266,6 +278,10 @@ class MailMailboxMessageListResponse(BaseModel):
total_count: int = 0
offset: int = 0
limit: int = 50
cursor: str | None = None
next_cursor: str | None = None
cursor_stable: bool = False
full: bool = False
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
@@ -276,4 +292,3 @@ class MailMailboxMessageResponse(BaseModel):
port: int | None = None
security: str | None = None
message: MailMailboxMessageDetailResponse

View File

@@ -115,6 +115,8 @@ class ImapMailboxMessageListResult:
total_count: int
offset: int
limit: int
uidvalidity: str | None = None
cursor_reset: bool = False
@dataclass(frozen=True, slots=True)
@@ -527,15 +529,23 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -
return parts
def _select_readonly(client: imaplib.IMAP4, folder: str) -> int:
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
typ, data = client.select(folder, readonly=True)
if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
selected_count = _decode_item(data[0] if data else None).strip()
try:
return max(0, int(selected_count))
total_count = max(0, int(selected_count))
except ValueError:
return 0
total_count = 0
uidvalidity = None
try:
_, uidvalidity_data = client.response("UIDVALIDITY")
if uidvalidity_data:
uidvalidity = _decode_item(uidvalidity_data[0]).strip() or None
except Exception:
uidvalidity = None
return total_count, uidvalidity
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
@@ -590,6 +600,63 @@ def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[
return summaries
def _search_message_uids(client: imaplib.IMAP4) -> list[str]:
typ, data = client.uid("search", None, "ALL")
if typ != "OK":
raise ImapAppendError(f"IMAP UID search failed: {data!r}", temporary=True)
uids: set[int] = set()
for item in data or []:
for token in _decode_item(item).split():
if token.isdigit():
uids.add(int(token))
return [str(uid) for uid in sorted(uids, reverse=True)]
def _paged_descending_uids(
uids: list[str],
*,
offset: int,
limit: int,
after_uid: str | None = None,
) -> tuple[list[str], int, bool]:
if not uids:
return [], 0, False
cursor_reset = False
if after_uid:
try:
start = uids.index(str(after_uid)) + 1
except ValueError:
start = 0
cursor_reset = True
else:
start = min(max(0, offset), len(uids))
return uids[start:start + limit], start, cursor_reset
def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not uids:
return []
typ, data = client.uid("fetch", _sequence_set(uids), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_uid: dict[str, ImapMailboxMessageSummary] = {}
for _sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
if not fetched_uid:
continue
by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
summaries: list[ImapMailboxMessageSummary] = []
for uid in uids:
summary = by_uid.get(str(uid))
if summary is not None:
summaries.append(summary)
continue
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
return summaries
def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip()
if folder:
@@ -612,7 +679,15 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace")
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult:
def list_imap_messages(
*,
imap_config: ImapConfig,
folder: str = "INBOX",
limit: int = 50,
offset: int = 0,
after_uid: str | None = None,
expected_uidvalidity: str | None = None,
) -> ImapMailboxMessageListResult:
"""List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config)
@@ -621,7 +696,20 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
offset = max(0, offset)
if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
page_records = records[offset:offset + limit]
cursor_reset = False
if expected_uidvalidity and expected_uidvalidity != "mock-v1":
effective_offset = 0
cursor_reset = True
elif after_uid:
record_ids = [str(record.get("id") or "") for record in records]
try:
effective_offset = record_ids.index(str(after_uid)) + 1
except ValueError:
effective_offset = 0
cursor_reset = True
else:
effective_offset = min(offset, len(records))
page_records = records[effective_offset:effective_offset + limit]
messages = [
_message_summary_from_raw(
uid=str(record.get("id") or ""),
@@ -632,14 +720,49 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
)
for record in page_records
]
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
messages=messages,
total_count=len(records),
offset=effective_offset,
limit=limit,
uidvalidity="mock-v1",
cursor_reset=cursor_reset,
)
client = _open_imap(imap_config)
try:
total_count = _select_readonly(client, folder)
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit)
total_count, uidvalidity = _select_readonly(client, folder)
uids = _search_message_uids(client)
cursor_reset = False
effective_after_uid = after_uid
effective_offset = offset
if expected_uidvalidity and expected_uidvalidity != uidvalidity:
effective_after_uid = None
effective_offset = 0
cursor_reset = True
page_uids, effective_offset, anchor_missing = _paged_descending_uids(
uids,
offset=effective_offset,
limit=limit,
after_uid=effective_after_uid,
)
messages = _fetch_message_summaries_by_uid(client, page_uids, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
messages=messages,
total_count=len(uids) if uids else total_count,
offset=effective_offset,
limit=limit,
uidvalidity=uidvalidity,
cursor_reset=cursor_reset or anchor_missing,
)
finally:
try:
client.logout()

View File

@@ -15,7 +15,7 @@
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"

View File

@@ -1,4 +1,4 @@
import type { ApiSettings } from "../types";
import type { ApiSettings, DeltaDeletedItem } from "../types";
import { apiFetch } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls";
@@ -95,6 +95,10 @@ export type MailMailboxMessageListResponse = {
total_count?: number;
offset?: number;
limit?: number;
cursor?: string | null;
next_cursor?: string | null;
cursor_stable?: boolean;
full?: boolean;
messages: MailMailboxMessageSummary[];
};
@@ -127,6 +131,16 @@ export type MailServerProfile = {
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export type MailSettingsDeltaResponse = {
profiles: MailServerProfile[];
policy?: MailProfilePolicyResponse | null;
changed_sections?: string[];
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
@@ -215,6 +229,28 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
return response.profiles ?? [];
}
export async function fetchMailSettingsDelta(
settings: ApiSettings,
params: {
scope_type?: MailProfileScope;
scope_id?: string | null;
include_inactive?: boolean;
campaign_id?: string | null;
since?: string | null;
limit?: number;
} = {}
): Promise<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> {
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
method: "POST",
@@ -284,9 +320,11 @@ export async function listMailboxMessages(
profileId: string,
folder = "INBOX",
limit = 50,
offset = 0
offset = 0,
cursor?: string | null
): Promise<MailMailboxMessageListResponse> {
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()}`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import {
LoadingIndicator,
MessageDisplayPanel,
formatDateTime,
i18nMessage,
type ApiSettings
} from "@govoplan/core-webui";
import {
@@ -17,11 +18,11 @@ import {
type MailImapFolderResponse,
type MailMailboxMessageDetail,
type MailMailboxMessageSummary,
type MailServerProfile
} from "../../api/mail";
type MailServerProfile } from
"../../api/mail";
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 [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
@@ -48,6 +49,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0);
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
@@ -60,10 +62,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = folderError || (noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.");
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 previewEmptyText = detailError || (loadingMessage ? "Loading message..." : "Select a message to inspect its content.");
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
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 ? "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 ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
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(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
@@ -88,10 +90,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (shellBusy || loadingMessage || filteredMessages.length === 0) return;
const selectedIndex = filteredMessages.findIndex((message) => mailboxMessageKey(message.folder || selectedFolder, message.uid) === selectedMessageKey);
let nextIndex: number | null = null;
if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;
else if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = filteredMessages.length - 1;
if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;else
if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;else
if (event.key === "Home") nextIndex = 0;else
if (event.key === "End") nextIndex = filteredMessages.length - 1;
if (nextIndex === null) return;
event.preventDefault();
const nextMessage = filteredMessages[nextIndex];
@@ -155,7 +157,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
try {
const response = await listMailboxFolders(settings, profileId);
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: [] }];
setFolders(loaded);
setExpandedFolders(new Set());
@@ -185,15 +187,19 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current;
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);
setMessageError("");
setDetailError("");
setError("");
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;
const loaded = response.messages ?? [];
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);
setMessageTotalCount(total);
setFolderMessageCount(folder, total);
@@ -269,8 +275,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);
else {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else
{
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
@@ -287,8 +293,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function toggleFolderNode(node: MailFolderNode) {
setExpandedFolders((current) => {
const next = new Set(current);
if (next.has(node.id)) next.delete(node.id);
else next.add(node.id);
if (next.has(node.id)) next.delete(node.id);else
next.add(node.id);
return next;
});
}
@@ -312,8 +318,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="Mailbox folders">
<div className="file-tree-heading">Folders</div>
<aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
<div className="file-tree-list">
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
<ExplorerTree
@@ -336,70 +342,70 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
</span>
{flagText && <small>{flagText}</small>}
</span>
);
}}
/>
</span>);
}} />
</div>
</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-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">
<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)}>
{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>)}
</select>
</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">
<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" />
Profiles
i18n:govoplan-mail.profiles.0c2a9300
</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" />
Folders
i18n:govoplan-mail.folders.19adc47b
</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" />
Messages
i18n:govoplan-mail.messages.f1702b46
</Button>
</div>
</div>
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "Mail"}</span>
<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 || "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>
</nav>
<div className="mailbox-filter-row">
<label className="mailbox-search-field">
<Search size={15} aria-hidden="true" />
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="Search messages" />
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="Clear message search"><X size={14} /></button>}
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="i18n:govoplan-mail.search_messages.abea65ae" />
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="i18n:govoplan-mail.clear_message_search.cc9f2800"><X size={14} /></button>}
</label>
</div>
<div className="file-list-meta">
<span>{messageCountLabel}</span>
<span>{selectedFolder}</span>
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} on page</span>}
{shellBusy && <span>Working...</span>}
{loadingMessage && <span>Loading preview...</span>}
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
</div>
<div className="file-list-table-head mailbox-message-head" role="row">
<span>Subject</span>
<span>Date</span>
<span>Size</span>
<span>i18n:govoplan-mail.subject.8d183dbd</span>
<span>i18n:govoplan-mail.date.eb9a4bc1</span>
<span>i18n:govoplan-mail.size.b7152342</span>
</div>
</div>
<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.map((message) => {
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
@@ -417,13 +423,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
event.preventDefault();
void openMessage(message);
}
}}
>
}}>
<div className="file-list-name-cell">
<div className="file-list-name">
<Mail className="file-row-icon" size={20} aria-hidden="true" />
<span>
<strong>{message.subject || "(no subject)"}</strong>
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
<small>{message.from_header || "-"}</small>
</span>
</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}
<span>{formatBytes(message.size_bytes)}</span>
</span>
</div>
);
</div>);
})}
</div>
</div>
@@ -444,22 +450,22 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
totalRows={messageTotalCount ?? 0}
disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize}
/>
onPageSizeChange={changeMessagePageSize} />
</section>
<section className="mailbox-preview-panel" aria-label="Message preview">
<div className="file-tree-heading">Preview</div>
<section className="mailbox-preview-panel" aria-label="i18n:govoplan-mail.message_preview.58de1450">
<div className="file-tree-heading">i18n:govoplan-mail.preview.f1fbb2b4</div>
<div className="mailbox-preview-scroll">
<MessageDisplayPanel
title={selectedMessage?.subject}
fields={selectedMessage ? [
{ label: "From", value: selectedMessage.from_header || "-" },
{ label: "To", value: selectedMessage.to_header || "-" },
{ label: "Cc", value: selectedMessage.cc_header || null },
{ label: "Bcc", value: selectedMessage.bcc_header || null },
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
] : []}
{ label: "i18n:govoplan-mail.from.3f66052a", value: selectedMessage.from_header || "-" },
{ label: "i18n:govoplan-mail.to.ae79ea1e", value: selectedMessage.to_header || "-" },
{ label: "i18n:govoplan-mail.cc.1fd6a880", value: selectedMessage.cc_header || null },
{ label: "i18n:govoplan-mail.bcc.8431acad", value: selectedMessage.bcc_header || null },
{ label: "i18n:govoplan-mail.date.eb9a4bc1", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }] :
[]}
bodyText={selectedMessage?.body_text}
bodyHtml={selectedMessage?.body_html}
bodyPreview={selectedMessage?.body_preview}
@@ -469,51 +475,55 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
contentType: attachment.content_type,
sizeBytes: attachment.size_bytes
}))}
emptyText={previewEmptyText}
/>
emptyText={previewEmptyText} />
</div>
</section>
{shellBusy && (
{shellBusy &&
<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>);
}
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 first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(totalRows, page * pageSize);
const options = [10, 25, 50, 100];
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>
<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))}>
{options.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<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="Previous page" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
<span>Page {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="Last page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight 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="i18n:govoplan-mail.previous_page.81f54719" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
<span>i18n:govoplan-mail.page.fb06270f {page} of {pageCount}</span>
<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="i18n:govoplan-mail.last_page.b01f16ae" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button>
</div>
</div>
);
</div>);
}
function mailboxMessageKey(folder: string, uid: string): string {
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[] {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return messages;
@@ -523,16 +533,16 @@ function filterMessages(messages: MailMailboxMessageSummary[], query: string): M
message.to_header,
message.cc_header,
message.date,
message.body_preview
].some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
message.body_preview].
some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
}
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
if (!ready) return "No folder loaded";
if (loading) return "Loading messages";
if (totalCount !== null && totalCount > visibleCount) return `${visibleCount} of ${totalCount} messages`;
if (!ready) return "i18n:govoplan-mail.no_folder_loaded.889bdb1b";
if (loading) return "i18n:govoplan-mail.loading_messages.4294022c";
if (totalCount !== null && totalCount > visibleCount) return i18nMessage("i18n:govoplan-mail.value_of_value_messages.981a1618", { value0: visibleCount, value1: totalCount });
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 {
@@ -544,17 +554,17 @@ function displayFolderFlag(flag: string): string | null {
const normalized = flag.replace(/^\\/, "").toLowerCase();
switch (normalized) {
case "sent":
return "Sent";
return "i18n:govoplan-mail.sent.35f49dcf";
case "drafts":
return "Drafts";
return "i18n:govoplan-mail.drafts.22a31d86";
case "trash":
return "Trash";
return "i18n:govoplan-mail.trash.e3bf62bb";
case "archive":
return "Archive";
return "i18n:govoplan-mail.archive.2621c6fd";
case "junk":
return "Junk";
return "i18n:govoplan-mail.junk.86c7d94c";
case "flagged":
return "Flagged";
return "i18n:govoplan-mail.flagged.f8db8a17";
case "haschildren":
case "hasnochildren":
case "noselect":
@@ -566,15 +576,15 @@ function displayFolderFlag(flag: string): string | null {
function transportLabel(profile: MailServerProfile): string {
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();
}
function formatBytes(value?: number | null): string {
if (!value) return "-";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-mail.bytes_kb", { value0: (value / 1024).toFixed(1) });
return i18nMessage("i18n:govoplan-mail.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function errorText(error: unknown): string {

View File

@@ -22,11 +22,11 @@ export type MailPolicyValidationMessage = {
};
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "SMTP host",
imap_hosts: "IMAP host",
envelope_senders: "Envelope sender",
from_headers: "From header",
recipient_domains: "Recipient domain"
smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b",
imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
};
type ValueCheck = {
@@ -36,8 +36,8 @@ type ValueCheck = {
export function validateMailPolicy(
policy: MailProfilePolicy | null | undefined,
input: MailPolicyValidationInput,
): MailPolicyValidationMessage[] {
input: MailPolicyValidationInput)
: MailPolicyValidationMessage[] {
if (!policy) return [];
const checks: ValueCheck[] = [
@@ -45,9 +45,9 @@ export function validateMailPolicy(
{ key: "imap_hosts", value: input.imapHost ?? "" },
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
{ key: "from_headers", value: input.fromHeader ?? "" },
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean)))
.map((value) => ({ key: "recipient_domains" as const, value }))
];
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))).
map((value) => ({ key: "recipient_domains" as const, value }))];
return checks.flatMap(({ key, value }) => {
const result = mailPolicyValueAllowed(policy, key, value);
@@ -58,9 +58,9 @@ export function validateMailPolicy(
label,
value: result.value,
severity: "danger" as const,
message: result.reason === "blacklist"
? `${label} ${result.value} is denied by pattern ${result.pattern}.`
: `${label} ${result.value} is outside the effective allow-list.`
message: result.reason === "blacklist" ?
`${label} ${result.value} is denied by pattern ${result.pattern}.` :
`${label} ${result.value} is outside the effective allow-list.`
}];
});
}
@@ -68,8 +68,8 @@ export function validateMailPolicy(
export function mailPolicyValueAllowed(
policy: MailProfilePolicy | null | undefined,
key: MailProfilePatternKey,
rawValue: string | null | undefined,
): { allowed: true; value: string } | { allowed: false; value: string; reason: "blacklist" | "whitelist"; pattern?: string } {
rawValue: string | null | undefined)
: {allowed: true;value: string;} | {allowed: false;value: string;reason: "blacklist" | "whitelist";pattern?: string;} {
const value = normalizePolicyValue(key, rawValue);
if (!value || !policy) return { allowed: true, value };
@@ -96,8 +96,8 @@ export function wildcardPatternMatches(pattern: string, rawValue: string): boole
function patternsFor(
rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"],
key: MailProfilePatternKey,
): string[] {
key: MailProfilePatternKey)
: string[] {
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 * from "./module";
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 { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/mail-profiles.css";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const mailModule: PlatformWebModule = {
id: "mail",
label: "Mail",
label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0",
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: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
],
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
},
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;
}
.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 {
max-height: min(76vh, 820px);
overflow: auto;
@@ -53,7 +29,6 @@
}
.mail-profile-form h3,
.mail-policy-section h3,
.mail-policy-pattern-grid h4 {
margin: 0;
color: var(--text-strong);
@@ -83,12 +58,6 @@
margin: 0;
}
.mail-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.mail-profile-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));