Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 3cd8c45c42
commit 3d82d7b32d
40 changed files with 1877 additions and 520 deletions

3
.gitignore vendored
View File

@@ -326,3 +326,6 @@ cython_debug/
# Built Visual Studio Code Extensions
*.vsix
# GovOPlaN WebUI test output
webui/.mail-test-build/

44
AGENTS.md Normal file
View File

@@ -0,0 +1,44 @@
# GovOPlaN Mail Codex Guide
## Scope
This repository owns the `mail` module: SMTP/IMAP profiles, mail profile policy, encrypted mail credentials, SMTP sending, IMAP append and mailbox access, mock mail infrastructure, backend module manifest, and `@govoplan/mail-webui`.
Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Campaign/files integrations must remain optional and go through core module metadata or capabilities.
## Local Commands
Install and run through core:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt
```
Focused backend tests:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
```
Focused WebUI tests:
```bash
cd /mnt/DATA/git/govoplan-mail/webui
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-ui
```
For combined checks, run:
```bash
cd /mnt/DATA/git/govoplan-core
./scripts/check-focused.sh
```
## Working Rules
- Keep mail behavior in this module, not core.
- Do not require campaign or files imports for mail-only startup.
- Shared WebUI components belong in core; mail WebUI should consume them through `@govoplan/core-webui`.
- Prefer mailbox/IMAP tests and targeted API smoke tests before full-suite runs.

View File

@@ -15,6 +15,10 @@ This repository owns:
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
## Credential inheritance policy
SMTP and IMAP each have one credential inheritance decision: descendants inherit profile credentials, may inherit profile credentials, or must provide local credentials. The lower-level override switch for `smtp_credentials.inherit` and `imap_credentials.inherit` decides whether child scopes may change that decision. There is no separate "override the override" credential policy field.
## Development
Install through the core environment:

View File

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

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.runtime import configure_runtime
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
apply_campaign_credentials,
assert_campaign_mail_policy_allows_json,
assert_mail_policy_allows_send,
effective_profile_credentials_inherited,
ensure_mail_profile_allowed_for_campaign,
imap_config_from_profile,
mail_profile_id_from_campaign_json,
materialize_campaign_mail_profile_config,
smtp_config_from_profile,
)
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes, send_email_message
class MailCampaignCapability:
MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError
SmtpSendError = SmtpSendError
ImapConfigurationError = ImapConfigurationError
ImapAppendError = ImapAppendError
materialize_campaign_mail_profile_config = staticmethod(materialize_campaign_mail_profile_config)
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
assert_mail_policy_allows_send = staticmethod(assert_mail_policy_allows_send)
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
ensure_mail_profile_allowed_for_campaign = staticmethod(ensure_mail_profile_allowed_for_campaign)
smtp_config_from_profile = staticmethod(smtp_config_from_profile)
imap_config_from_profile = staticmethod(imap_config_from_profile)
effective_profile_credentials_inherited = staticmethod(effective_profile_credentials_inherited)
apply_campaign_credentials = staticmethod(apply_campaign_credentials)
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
send_email_bytes = staticmethod(send_email_bytes)
append_message_to_sent = staticmethod(append_message_to_sent)
send_email_message = staticmethod(send_email_message)
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
configure_runtime(settings=context.settings)
return MailCampaignCapability()

View File

@@ -1,35 +1,21 @@
from __future__ import annotations
from enum import StrEnum
from govoplan_core.mail.config import (
ImapConfig,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
StrictModel,
TransportCredentials,
TransportSecurity,
)
from pydantic import BaseModel, ConfigDict, Field
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
class TransportSecurity(StrEnum):
PLAIN = "plain"
TLS = "tls"
STARTTLS = "starttls"
class SmtpConfig(StrictModel):
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = None
password: str | None = None
security: TransportSecurity = TransportSecurity.STARTTLS
timeout_seconds: int = Field(default=30, ge=1)
class ImapConfig(StrictModel):
enabled: bool = False
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = None
password: str | None = None
security: TransportSecurity = TransportSecurity.TLS
sent_folder: str = "auto"
timeout_seconds: int = Field(default=30, ge=1)
__all__ = [
"ImapConfig",
"ImapServerConfig",
"SmtpConfig",
"SmtpServerConfig",
"StrictModel",
"TransportCredentials",
"TransportSecurity",
]

View File

@@ -29,8 +29,10 @@ class MailServerProfile(Base, TimestampMixin):
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
smtp_username: Mapped[str | None] = mapped_column(String(320))
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
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)

View File

@@ -10,8 +10,8 @@ from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from govoplan_core.admin.governance import get_system_settings
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
from govoplan_campaign.backend.db.models import Campaign
from govoplan_mail.backend.db.models import MailServerProfile
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
@@ -25,20 +25,47 @@ MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy"
PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"}
PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "envelope_senders", "from_headers", "recipient_domains")
PROFILE_SCOPE_ORDER = {"system": 0, "tenant": 1, "user": 2, "group": 2, "campaign": 3}
MAIL_POLICY_LIMIT_KEYS = (
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"allow_campaign_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains",
)
def default_mail_policy_lower_level_limits() -> dict[str, bool]:
return {key: True for key in MAIL_POLICY_LIMIT_KEYS}
def _campaign_model():
try:
from govoplan_campaign.backend.db.models import Campaign
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_campaign")
raise MailProfileError("Campaign module is not installed") from exc
return Campaign
@dataclass(slots=True)
class EffectiveCredentialPolicy:
inherit: bool = True
allow_override: bool = True
explicit_inherit: bool = False
inherit_source: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"inherit": self.inherit,
"allow_override": self.allow_override,
}
return {"inherit": self.inherit}
@dataclass(slots=True)
@@ -51,6 +78,7 @@ class EffectiveMailProfilePolicy:
imap_credentials: EffectiveCredentialPolicy = field(default_factory=EffectiveCredentialPolicy)
whitelist_groups: dict[str, list[list[str]]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS})
blacklist_patterns: dict[str, list[str]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS})
allow_lower_level_limits: dict[str, bool] = field(default_factory=default_mail_policy_lower_level_limits)
source_policies: list[dict[str, Any]] = field(default_factory=list)
def profile_id_allowed(self, profile_id: str) -> bool:
@@ -87,6 +115,7 @@ class EffectiveMailProfilePolicy:
"imap_credentials": self.imap_credentials.as_dict(),
"whitelist": whitelist,
"blacklist": blacklist,
"allow_lower_level_limits": dict(self.allow_lower_level_limits),
}
@@ -95,11 +124,14 @@ def slugify_profile_name(value: str) -> str:
return slug or "mail-profile"
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, bool]:
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]:
payload = config.model_dump(mode="json")
username_was_supplied = "username" in config.model_fields_set
password_was_supplied = "password" in config.model_fields_set
username = payload.pop("username", None)
password = payload.pop("password", None)
return payload, password, password_was_supplied
payload.pop("enabled", None)
return payload, username, password, username_was_supplied, password_was_supplied
def _normalize_scope_type(scope_type: str | None) -> str:
@@ -162,11 +194,20 @@ def _normalize_credential_policy(value: Any) -> dict[str, bool | None]:
inherit = data.get("inherit")
if inherit is None:
inherit = data.get("inherit_credentials")
allow_override = data.get("allow_override")
return {
"inherit": inherit if isinstance(inherit, bool) else None,
"allow_override": allow_override if isinstance(allow_override, bool) else None,
}
return {"inherit": inherit if isinstance(inherit, bool) else None}
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool = False) -> dict[str, bool]:
if value is None:
return default_mail_policy_lower_level_limits() if fill_defaults else {}
if not isinstance(value, dict):
raise MailProfileError("Mail profile lower-level limit policy must be an object")
normalized = default_mail_policy_lower_level_limits() if fill_defaults else {}
for key in MAIL_POLICY_LIMIT_KEYS:
raw_value = value.get(key)
if isinstance(raw_value, bool):
normalized[key] = raw_value
return normalized
def normalize_mail_profile_policy(payload: dict[str, Any] | None) -> dict[str, Any]:
@@ -180,6 +221,7 @@ def normalize_mail_profile_policy(payload: dict[str, Any] | None) -> dict[str, A
"imap_credentials": _normalize_credential_policy(data.get("imap_credentials")),
"whitelist": whitelist,
"blacklist": blacklist,
"allow_lower_level_limits": _normalize_allow_lower_level_limits(data.get("allow_lower_level_limits")),
}
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
value = data.get(key)
@@ -211,68 +253,117 @@ def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False
if isinstance(credential, dict):
if isinstance(credential.get("inherit"), bool):
fields.append(f"{key}.inherit")
if isinstance(credential.get("allow_override"), bool):
fields.append(f"{key}.allow_override")
for kind in ("whitelist", "blacklist"):
rules = policy.get(kind) or {}
if isinstance(rules, dict):
for key in PROFILE_PATTERN_KEYS:
if _clean_string_list(rules.get(key, [])):
fields.append(f"{kind}.{key}")
allow_lower = policy.get("allow_lower_level_limits") or {}
if isinstance(allow_lower, dict):
for key in MAIL_POLICY_LIMIT_KEYS:
if isinstance(allow_lower.get(key), bool):
fields.append(f"allow_lower_level_limits.{key}")
if baseline and not fields:
fields.append("defaults")
return fields
def _mail_policy_source_policy(policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
normalized = normalize_mail_profile_policy(policy)
if not baseline:
return normalized
source_policy = EffectiveMailProfilePolicy().as_dict()
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids:
source_policy["allowed_profile_ids"] = allowed_ids
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
value = normalized.get(key)
if isinstance(value, bool):
source_policy[key] = value
for key in ("smtp_credentials", "imap_credentials"):
credential = normalized.get(key) or {}
source_credential = dict(source_policy.get(key) or {})
if isinstance(credential, dict):
if isinstance(credential.get("inherit"), bool):
source_credential["inherit"] = credential["inherit"]
source_policy[key] = source_credential
source_policy["whitelist"] = dict(normalized.get("whitelist") or {})
source_policy["blacklist"] = dict(normalized.get("blacklist") or {})
source_policy["allow_lower_level_limits"] = {
**default_mail_policy_lower_level_limits(),
**(normalized.get("allow_lower_level_limits") or {}),
}
return source_policy
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),
}
def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None:
inherit = data.get("inherit")
if isinstance(inherit, bool):
if current.allow_override:
current.inherit = inherit
current.explicit_inherit = True
current.inherit_source = source
elif inherit == current.inherit:
current.explicit_inherit = True
allow_override = data.get("allow_override")
if isinstance(allow_override, bool):
if current.allow_override:
current.allow_override = allow_override
elif allow_override is False:
current.allow_override = False
def _merge_credential_policy(
current: EffectiveCredentialPolicy,
data: dict[str, Any],
*,
source: str,
lower_limits: dict[str, bool],
inherit_limit_key: str,
) -> None:
inherit = data.get("inherit")
if isinstance(inherit, bool) and lower_limits.get(inherit_limit_key, True):
current.inherit = inherit
current.explicit_inherit = True
current.inherit_source = source
def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str, source_id: str | None = None, label: str | None = None, baseline: bool = False) -> None:
normalized = normalize_mail_profile_policy(data)
policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline))
lower_limits = dict(policy.allow_lower_level_limits)
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids:
if allowed_ids and lower_limits.get("allowed_profile_ids", True):
policy.allowed_profile_id_sets.append(set(allowed_ids))
if normalized.get("allow_user_profiles") is False:
if normalized.get("allow_user_profiles") is False and lower_limits.get("allow_user_profiles", True):
policy.allow_user_profiles = False
if normalized.get("allow_group_profiles") is False:
if normalized.get("allow_group_profiles") is False and lower_limits.get("allow_group_profiles", True):
policy.allow_group_profiles = False
if normalized.get("allow_campaign_profiles") is False:
if normalized.get("allow_campaign_profiles") is False and lower_limits.get("allow_campaign_profiles", True):
policy.allow_campaign_profiles = False
_merge_credential_policy(policy.smtp_credentials, normalized.get("smtp_credentials") or {}, source=source)
_merge_credential_policy(policy.imap_credentials, normalized.get("imap_credentials") or {}, source=source)
_merge_credential_policy(
policy.smtp_credentials,
normalized.get("smtp_credentials") or {},
source=source,
lower_limits=lower_limits,
inherit_limit_key="smtp_credentials.inherit",
)
_merge_credential_policy(
policy.imap_credentials,
normalized.get("imap_credentials") or {},
source=source,
lower_limits=lower_limits,
inherit_limit_key="imap_credentials.inherit",
)
whitelist = normalized.get("whitelist") or {}
blacklist = normalized.get("blacklist") or {}
for key in PROFILE_PATTERN_KEYS:
allow_patterns = _meaningful_allow_patterns(whitelist.get(key, []))
if allow_patterns:
if allow_patterns and lower_limits.get(f"whitelist.{key}", True):
policy.whitelist_groups.setdefault(key, []).append(allow_patterns)
deny_patterns = _clean_string_list(blacklist.get(key, []))
if deny_patterns:
if deny_patterns and lower_limits.get(f"blacklist.{key}", True):
policy.blacklist_patterns.setdefault(key, []).extend(deny_patterns)
local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if local_lower_limits:
policy.allow_lower_level_limits = {
key: lower_limits.get(key, True) and local_lower_limits.get(key, lower_limits.get(key, True))
for key in MAIL_POLICY_LIMIT_KEYS
}
def _owner_context(
@@ -282,10 +373,10 @@ def _owner_context(
campaign_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> tuple[Campaign | None, str | None, str | None]:
) -> tuple[Any | None, str | None, str | None]:
campaign = None
if campaign_id:
campaign = session.get(Campaign, campaign_id)
campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy")
owner_user_id = campaign.owner_user_id
@@ -329,6 +420,38 @@ def effective_mail_profile_policy(
return policy
def effective_mail_profile_policy_for_scope(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
campaign_id: str | None = None,
) -> EffectiveMailProfilePolicy:
clean_scope = _normalize_scope_type(scope_type)
if campaign_id:
return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if clean_scope == "system":
policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session)
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
return policy
if clean_scope == "tenant":
return effective_mail_profile_policy(session, tenant_id=tenant_id)
if clean_scope == "user":
if not scope_id:
raise MailProfileError("User mail profile policy requires scope_id")
return effective_mail_profile_policy(session, tenant_id=tenant_id, owner_user_id=scope_id)
if clean_scope == "group":
if not scope_id:
raise MailProfileError("Group mail profile policy requires scope_id")
return effective_mail_profile_policy(session, tenant_id=tenant_id, owner_group_id=scope_id)
if not scope_id:
raise MailProfileError("Campaign mail profile policy requires scope_id")
return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id)
def parent_mail_profile_policy(
session: Session,
*,
@@ -353,7 +476,7 @@ def parent_mail_profile_policy(
if clean_scope != "campaign" or not scope_id:
return policy
campaign = session.get(Campaign, scope_id)
campaign = session.get(_campaign_model(), scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
if campaign.owner_user_id:
@@ -470,7 +593,7 @@ def _scope_tuple_for_create(
return tenant_id, clean_scope, clean_scope_id
if not clean_scope_id:
raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id")
campaign = session.get(Campaign, clean_scope_id)
campaign = session.get(_campaign_model(), clean_scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign scope is not part of the active tenant")
return tenant_id, clean_scope, clean_scope_id
@@ -524,7 +647,7 @@ def _profile_visible_filter(tenant_id: str):
return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id)
def _context_profile_clauses(campaign: Campaign, policy: EffectiveMailProfilePolicy) -> list[Any]:
def _context_profile_clauses(campaign: Any, policy: EffectiveMailProfilePolicy) -> list[Any]:
clauses: list[Any] = [
MailServerProfile.scope_type == "system",
and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"),
@@ -551,7 +674,7 @@ def _profile_allowed_for_context(
*,
tenant_id: str,
policy: EffectiveMailProfilePolicy,
campaign: Campaign | None = None,
campaign: Any | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> bool:
@@ -588,7 +711,7 @@ def list_mail_server_profiles(
campaign_id: str | None = None,
) -> list[MailServerProfile]:
if campaign_id:
campaign = session.get(Campaign, campaign_id)
campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profiles")
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
@@ -639,7 +762,7 @@ def ensure_mail_profile_allowed_for_campaign(
profile_id: str,
require_active: bool = True,
) -> MailServerProfile:
campaign = session.get(Campaign, campaign_id)
campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy")
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active)
@@ -668,10 +791,18 @@ def _legacy_profile_credentials_inherited(server: dict[str, Any], protocol: str)
return server.get(f"inherit_{protocol}_credentials") is not False
def _local_credential_inheritance_override_allowed(policy: EffectiveMailProfilePolicy, protocol: str) -> bool:
credential_policy = _credential_policy_for_protocol(policy, protocol)
return (
credential_policy.inherit_source != "campaign"
and policy.allow_lower_level_limits.get(f"{protocol}_credentials.inherit", True)
)
def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool:
credential_policy = _credential_policy_for_protocol(policy, protocol)
legacy_key = f"inherit_{protocol}_credentials"
if legacy_key in server and credential_policy.allow_override and credential_policy.inherit_source != "campaign":
if legacy_key in server and _local_credential_inheritance_override_allowed(policy, protocol):
return _legacy_profile_credentials_inherited(server, protocol)
return credential_policy.inherit
@@ -718,7 +849,7 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p
legacy_key = f"inherit_{protocol}_credentials"
if legacy_key in server:
legacy_inherit = _legacy_profile_credentials_inherited(server, protocol)
if (not credential_policy.allow_override or credential_policy.inherit_source == "campaign") and legacy_inherit != credential_policy.inherit:
if not _local_credential_inheritance_override_allowed(policy, protocol) and legacy_inherit != credential_policy.inherit:
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy")
inherited = profile_credentials_inherited_for_server(policy, server, protocol)
username, password = _transport_credentials_from_server(server, protocol)
@@ -728,14 +859,16 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
def _inline_transport_configured(value: Any) -> bool:
if not isinstance(value, dict):
return False
if value.get("enabled") is True:
return True
for key in ("host", "username", "password"):
def _inline_transport_configured(server: dict[str, Any], protocol: str) -> bool:
value = server.get(protocol) if isinstance(server.get(protocol), dict) else {}
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
credential_value = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
for key in ("host",):
if str(value.get(key) or "").strip():
return True
for key in ("username", "password"):
if str(credential_value.get(key) or value.get(key) or "").strip():
return True
return False
@@ -784,7 +917,7 @@ def assert_campaign_mail_policy_allows_json(
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
if campaign_id:
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if not policy.allow_campaign_profiles and (_inline_transport_configured(smtp) or _inline_transport_configured(imap)):
if not policy.allow_campaign_profiles and (_inline_transport_configured(server, "smtp") or _inline_transport_configured(server, "imap")):
raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
_assert_transport_values_allowed(policy, smtp, imap)
return
@@ -834,11 +967,12 @@ def create_mail_server_profile(
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap)
clean_slug = slugify_profile_name(slug or clean_name)
_ensure_unique_slug(session, scope_type=clean_scope_type, scope_id=clean_scope_id, slug=clean_slug)
smtp_payload, smtp_password, _ = _transport_payload(smtp)
smtp_payload, smtp_username, smtp_password, _, _ = _transport_payload(smtp)
imap_payload = None
imap_username = None
imap_password = None
if imap is not None:
imap_payload, imap_password, _ = _transport_payload(imap)
imap_payload, imap_username, imap_password, _, _ = _transport_payload(imap)
profile = MailServerProfile(
tenant_id=profile_tenant_id,
scope_type=clean_scope_type,
@@ -848,8 +982,10 @@ def create_mail_server_profile(
description=description,
is_active=is_active,
smtp_config=smtp_payload,
smtp_username=smtp_username,
smtp_password_encrypted=encrypt_secret(smtp_password),
imap_config=imap_payload,
imap_username=imap_username,
imap_password_encrypted=encrypt_secret(imap_password),
created_by_user_id=user_id,
updated_by_user_id=user_id,
@@ -893,13 +1029,13 @@ def update_mail_server_profile(
if is_active is not None:
profile.is_active = is_active
next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "password": decrypt_secret(profile.smtp_password_encrypted)})
next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "username": _profile_username(profile, "smtp"), "password": decrypt_secret(profile.smtp_password_encrypted)})
if clear_imap:
next_imap = None
elif imap is not None:
next_imap = imap
elif profile.imap_config:
next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "password": decrypt_secret(profile.imap_password_encrypted)})
next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "username": _profile_username(profile, "imap"), "password": decrypt_secret(profile.imap_password_encrypted)})
else:
next_imap = None
@@ -915,17 +1051,22 @@ def update_mail_server_profile(
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap)
if smtp is not None:
smtp_payload, smtp_password, supplied = _transport_payload(smtp)
smtp_payload, smtp_username, smtp_password, username_supplied, password_supplied = _transport_payload(smtp)
profile.smtp_config = smtp_payload
if supplied:
if username_supplied:
profile.smtp_username = smtp_username
if password_supplied:
profile.smtp_password_encrypted = encrypt_secret(smtp_password)
if clear_imap:
profile.imap_config = None
profile.imap_username = None
profile.imap_password_encrypted = None
elif imap is not None:
imap_payload, imap_password, supplied = _transport_payload(imap)
imap_payload, imap_username, imap_password, username_supplied, password_supplied = _transport_payload(imap)
profile.imap_config = imap_payload
if supplied:
if username_supplied:
profile.imap_username = imap_username
if password_supplied:
profile.imap_password_encrypted = encrypt_secret(imap_password)
profile.updated_by_user_id = user_id
session.add(profile)
@@ -933,8 +1074,18 @@ def update_mail_server_profile(
return profile
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
if protocol == "smtp":
return profile.smtp_username or (profile.smtp_config or {}).get("username")
if protocol == "imap":
return profile.imap_username or (profile.imap_config or {}).get("username")
return None
def smtp_config_from_profile(profile: MailServerProfile) -> SmtpConfig:
payload = dict(profile.smtp_config or {})
payload.pop("username", None)
payload["username"] = _profile_username(profile, "smtp")
payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
return SmtpConfig.model_validate(payload)
@@ -943,6 +1094,9 @@ def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None:
if not profile.imap_config:
return None
payload = dict(profile.imap_config or {})
payload.pop("username", None)
payload.pop("enabled", None)
payload["username"] = _profile_username(profile, "imap")
payload["password"] = decrypt_secret(profile.imap_password_encrypted)
return ImapConfig.model_validate(payload)
@@ -952,11 +1106,15 @@ def inherit_profile_credentials(server: dict[str, Any], protocol: str) -> bool:
def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]:
config = server.get(protocol)
if not isinstance(config, dict):
return None, None
username = config.get("username")
password = config.get("password")
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
config = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else None
legacy_config = server.get(protocol) if isinstance(server.get(protocol), dict) else None
username = config.get("username") if config is not None else None
password = config.get("password") if config is not None else None
if username in (None, "") and legacy_config is not None:
username = legacy_config.get("username")
if password in (None, "") and legacy_config is not None:
password = legacy_config.get("password")
return (str(username) if username not in (None, "") else None, str(password) if password not in (None, "") else None)
@@ -967,6 +1125,14 @@ def apply_campaign_credentials(payload: dict[str, Any], server: dict[str, Any],
return payload
def _server_config_payload(value: dict[str, Any] | None) -> dict[str, Any]:
payload = dict(value or {})
payload.pop("username", None)
payload.pop("password", None)
payload.pop("enabled", None)
return payload
def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
return {
"id": profile.id,
@@ -977,8 +1143,12 @@ def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
"slug": profile.slug,
"description": profile.description,
"is_active": profile.is_active,
"smtp": dict(profile.smtp_config or {}),
"imap": dict(profile.imap_config or {}) if profile.imap_config else None,
"smtp": _server_config_payload(profile.smtp_config),
"imap": _server_config_payload(profile.imap_config) if profile.imap_config else None,
"credentials": {
"smtp": {"username": _profile_username(profile, "smtp")},
"imap": {"username": _profile_username(profile, "imap")},
},
"smtp_password_configured": bool(profile.smtp_password_encrypted),
"imap_password_configured": bool(profile.imap_password_encrypted),
"created_at": profile.created_at,
@@ -1013,22 +1183,36 @@ def get_mail_profile_policy(
if group is None or group.tenant_id != tenant_id:
raise MailProfileError("Group policy not found")
return _policy_from_attr(group.mail_profile_policy)
campaign = session.get(Campaign, scope_id)
campaign = session.get(_campaign_model(), scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
return _policy_from_attr(campaign.mail_profile_policy)
def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None:
parent_limits = parent.allow_lower_level_limits
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids and not parent_limits.get("allowed_profile_ids", True):
raise MailProfileError("Mail profile allow-list is locked by an ancestor policy")
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
if isinstance(normalized.get(key), bool) and not parent_limits.get(key, True):
raise MailProfileError(f"{key} is locked by an ancestor policy")
for kind in ("whitelist", "blacklist"):
rules = normalized.get(kind) or {}
if isinstance(rules, dict):
for key in PROFILE_PATTERN_KEYS:
if _clean_string_list(rules.get(key, [])) and not parent_limits.get(f"{kind}.{key}", True):
raise MailProfileError(f"{kind}.{key} is locked by an ancestor policy")
for protocol in ("smtp", "imap"):
local = normalized.get(f"{protocol}_credentials") or {}
parent_credentials = _credential_policy_for_protocol(parent, protocol)
local_inherit = local.get("inherit")
if isinstance(local_inherit, bool) and not parent_credentials.allow_override and local_inherit != parent_credentials.inherit:
if isinstance(local_inherit, bool) and not parent_limits.get(f"{protocol}_credentials.inherit", True):
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by an ancestor policy")
local_allow_override = local.get("allow_override")
if local_allow_override is True and not parent_credentials.allow_override:
raise MailProfileError(f"{protocol.upper()} credential override cannot be re-enabled below a locked ancestor policy")
local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if isinstance(local_lower_limits, dict):
for key, allowed in local_lower_limits.items():
if allowed is True and not parent_limits.get(key, True):
raise MailProfileError(f"{key} lower-level limiting cannot be re-enabled below a locked ancestor policy")
def set_mail_profile_policy(
@@ -1078,7 +1262,7 @@ def set_mail_profile_policy(
group.mail_profile_policy = normalized
session.add(group)
return normalized
campaign = session.get(Campaign, scope_id)
campaign = session.get(_campaign_model(), scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
campaign.mail_profile_policy = normalized
@@ -1086,6 +1270,23 @@ def set_mail_profile_policy(
return normalized
def _write_transport_to_server(server: dict[str, Any], protocol: str, payload: dict[str, Any]) -> None:
server_payload = dict(payload)
username = server_payload.pop("username", None)
password = server_payload.pop("password", None)
server_payload.pop("enabled", None)
server[protocol] = server_payload
if username not in (None, "") or password not in (None, ""):
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
protocol_credentials = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
if username not in (None, ""):
protocol_credentials["username"] = username
if password not in (None, ""):
protocol_credentials["password"] = password
credentials[protocol] = protocol_credentials
server["credentials"] = credentials
def materialize_campaign_mail_profile_config(
session: Session,
*,
@@ -1133,13 +1334,13 @@ def materialize_campaign_mail_profile_config(
smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json")
if not profile_credentials_inherited_for_server(policy, server, "smtp"):
smtp_payload = apply_campaign_credentials(smtp_payload, server, "smtp")
resolved_server["smtp"] = smtp_payload
_write_transport_to_server(resolved_server, "smtp", smtp_payload)
imap = imap_config_from_profile(profile)
if imap is not None:
imap_payload = imap.model_dump(mode="json")
if not profile_credentials_inherited_for_server(policy, server, "imap"):
imap_payload = apply_campaign_credentials(imap_payload, server, "imap")
resolved_server["imap"] = imap_payload
_write_transport_to_server(resolved_server, "imap", imap_payload)
else:
resolved_server.pop("imap", None)
data["server"] = resolved_server

View File

@@ -82,6 +82,9 @@ manifest = ModuleManifest(
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
),
capability_factories={
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
},
)

View File

@@ -25,7 +25,7 @@ from govoplan_core.db.session import get_session
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
create_mail_server_profile,
effective_mail_profile_policy,
effective_mail_profile_policy_for_scope,
get_mail_profile_policy,
get_mail_server_profile,
imap_config_from_profile,
@@ -36,6 +36,7 @@ from govoplan_mail.backend.mail_profiles import (
smtp_config_from_profile,
update_mail_server_profile,
)
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
@@ -172,7 +173,7 @@ def create_profile(
session: Session = Depends(get_session),
):
_require_profile_write_scope(principal, payload.scope_type)
if payload.smtp.password or (payload.imap and payload.imap.password):
if payload.credentials_supplied():
_require_profile_credentials_scope(principal, payload.scope_type)
try:
profile = create_mail_server_profile(
@@ -182,8 +183,8 @@ def create_profile(
name=payload.name,
slug=payload.slug,
description=payload.description,
smtp=payload.smtp,
imap=payload.imap,
smtp=payload.smtp_config(),
imap=payload.imap_config(),
is_active=payload.is_active,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
@@ -219,8 +220,18 @@ def update_profile(
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
_require_profile_write_scope(principal, profile.scope_type or "tenant")
if (payload.smtp and "password" in payload.smtp.model_fields_set) or (payload.imap and "password" in payload.imap.model_fields_set) or payload.clear_imap:
if payload.credentials_supplied() or payload.clear_imap:
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
smtp_config = payload.smtp_config()
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
smtp_data = dict(profile.smtp_config or {})
smtp_data.update(payload.credentials.smtp.model_dump(mode="json", exclude_unset=True))
smtp_config = SmtpConfig.model_validate(smtp_data)
imap_config = payload.imap_config()
if payload.imap is None and "imap" in payload.credentials.model_fields_set and profile.imap_config:
imap_data = dict(profile.imap_config or {})
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
imap_config = ImapConfig.model_validate(imap_data)
update_mail_server_profile(
session,
profile,
@@ -230,8 +241,8 @@ def update_profile(
slug=payload.slug,
description=payload.description if "description" in payload.model_fields_set else None,
is_active=payload.is_active,
smtp=payload.smtp,
imap=payload.imap,
smtp=smtp_config,
imap=imap_config,
clear_imap=payload.clear_imap,
)
session.commit()
@@ -273,16 +284,15 @@ def read_mail_profile_policy(
_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 = None
effective_sources = []
if campaign_id:
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
elif scope_type == "campaign" and scope_id:
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
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 []
@@ -310,12 +320,14 @@ def write_mail_profile_policy(
policy=payload.policy.model_dump(mode="json"),
)
session.commit()
effective = None
effective_sources = []
if scope_type == "campaign" and scope_id:
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
effective_policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=principal.tenant_id,
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 []
@@ -379,7 +391,7 @@ def list_profile_imap_folders(
if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration")
result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -397,7 +409,7 @@ def list_profile_mailbox_folders(
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -412,13 +424,14 @@ 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),
offset: int = Query(default=0, ge=0, le=100000),
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)
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset)
return MailMailboxMessageListResponse(
profile_id=profile_id,
folder=result.folder,
@@ -426,6 +439,8 @@ def list_profile_mailbox_messages(
port=result.port,
security=result.security,
total_count=result.total_count,
offset=result.offset,
limit=result.limit,
messages=[_mailbox_summary_response(message) for message in result.messages],
)
except MailProfileError as exc:
@@ -551,7 +566,7 @@ def list_imap_folder_settings(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
try:
result = list_imap_folders(imap_config=payload)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(
ok=True,
host=result.host,

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials
class MailSmtpTestRequest(SmtpConfig):
@@ -15,7 +15,50 @@ class MailSmtpTestRequest(SmtpConfig):
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
class MailTransportCredentialsPayload(TransportCredentials):
"""Credentials supplied separately from saved server settings."""
class MailServerProfileCredentialsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
smtp: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
imap: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
def _normalize_profile_transport_payload(value: object) -> object:
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]:
payload = server.model_dump(mode="json")
if "username" in credentials.model_fields_set:
payload["username"] = credentials.username
if "password" in credentials.model_fields_set:
payload["password"] = credentials.password
return payload
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
@@ -25,7 +68,6 @@ class MailCredentialPolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
inherit: bool | None = None
allow_override: bool | None = None
class MailProfilePolicyPayload(BaseModel):
@@ -39,6 +81,7 @@ class MailProfilePolicyPayload(BaseModel):
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
whitelist: dict[str, list[str]] = Field(default_factory=dict)
blacklist: dict[str, list[str]] = Field(default_factory=dict)
allow_lower_level_limits: dict[str, bool] = Field(default_factory=dict)
class MailProfilePolicyUpdateRequest(BaseModel):
@@ -52,6 +95,7 @@ class PolicySourceStepResponse(BaseModel):
scope_id: str | None = None
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
class MailProfilePolicyResponse(BaseModel):
@@ -73,8 +117,29 @@ class MailServerProfileCreateRequest(BaseModel):
is_active: bool = True
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
smtp: SmtpConfig
imap: ImapConfig | None = None
smtp: SmtpServerConfig
imap: ImapServerConfig | None = None
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
@model_validator(mode="before")
@classmethod
def normalize_legacy_transport_payload(cls, value: object) -> object:
return _normalize_profile_transport_payload(value)
def smtp_config(self) -> SmtpConfig:
return SmtpConfig.model_validate(_merge_transport_credentials(self.smtp, self.credentials.smtp))
def imap_config(self) -> ImapConfig | None:
if self.imap is None:
return None
return ImapConfig.model_validate(_merge_transport_credentials(self.imap, self.credentials.imap))
def credentials_supplied(self) -> bool:
return any(
field in credentials.model_fields_set
for credentials in (self.credentials.smtp, self.credentials.imap)
for field in ("username", "password")
)
class MailServerProfileUpdateRequest(BaseModel):
@@ -84,10 +149,35 @@ class MailServerProfileUpdateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool | None = None
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
smtp: SmtpServerConfig | None = None
imap: ImapServerConfig | None = None
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
clear_imap: bool = False
@model_validator(mode="before")
@classmethod
def normalize_legacy_transport_payload(cls, value: object) -> object:
return _normalize_profile_transport_payload(value)
def smtp_config(self) -> SmtpConfig | None:
if self.smtp is None and "smtp" not in self.credentials.model_fields_set:
return None
server = self.smtp or SmtpServerConfig()
return SmtpConfig.model_validate(_merge_transport_credentials(server, self.credentials.smtp))
def imap_config(self) -> ImapConfig | None:
if self.imap is None and "imap" not in self.credentials.model_fields_set:
return None
server = self.imap or ImapServerConfig()
return ImapConfig.model_validate(_merge_transport_credentials(server, self.credentials.imap))
def credentials_supplied(self) -> bool:
return any(
field in credentials.model_fields_set
for credentials in (self.credentials.smtp, self.credentials.imap)
for field in ("username", "password")
)
class MailServerProfileResponse(BaseModel):
id: str
@@ -100,6 +190,7 @@ class MailServerProfileResponse(BaseModel):
is_active: bool
smtp: dict[str, Any]
imap: dict[str, Any] | None = None
credentials: dict[str, Any] = Field(default_factory=dict)
smtp_password_configured: bool = False
imap_password_configured: bool = False
created_at: datetime
@@ -123,6 +214,8 @@ class MailConnectionTestResponse(BaseModel):
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
message_count: int | None = None
unseen_count: int | None = None
class MailImapFolderListResponse(BaseModel):
@@ -171,6 +264,8 @@ class MailMailboxMessageListResponse(BaseModel):
port: int | None = None
security: str | None = None
total_count: int = 0
offset: int = 0
limit: int = 50
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)

View File

@@ -50,6 +50,8 @@ class ImapLoginTestResult:
class ImapMailboxInfo:
name: str
flags: list[str]
message_count: int | None = None
unseen_count: int | None = None
@dataclass(frozen=True, slots=True)
@@ -111,6 +113,8 @@ class ImapMailboxMessageListResult:
folder: str
messages: list[ImapMailboxMessageSummary]
total_count: int
offset: int
limit: int
@dataclass(frozen=True, slots=True)
@@ -133,8 +137,6 @@ class ImapAppendResult:
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
if not config.enabled:
raise ImapConfigurationError("IMAP is disabled")
if not config.host:
raise ImapConfigurationError("IMAP host is required")
if not config.port:
@@ -311,7 +313,12 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS]
records = list_records(limit=500)
folders = []
for item in MOCK_IMAP_FOLDERS:
name = str(item["name"])
count = sum(1 for record in records if _mock_folder_matches(record, name))
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
return ImapFolderListResult(
host=host,
port=port,
@@ -334,7 +341,8 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
continue
name, flags = extracted
parsed.append((name, flags))
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name)
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
return ImapFolderListResult(
host=host,
@@ -350,6 +358,31 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
pass
def _has_folder_flag(flags: set[str], flag: str) -> bool:
wanted = flag.casefold().lstrip("\\")
return any(item.casefold().lstrip("\\") == wanted for item in flags)
def _quote_mailbox_name(name: str) -> str:
return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]:
try:
typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)")
except Exception:
return None, None
if typ != "OK":
return None, None
text = " ".join(_decode_item(item) for item in data or [])
messages_match = re.search(r"\bMESSAGES\s+(\d+)", text, re.IGNORECASE)
unseen_match = re.search(r"\bUNSEEN\s+(\d+)", text, re.IGNORECASE)
return (
int(messages_match.group(1)) if messages_match else None,
int(unseen_match.group(1)) if unseen_match else None,
)
def _parse_message(raw: bytes) -> EmailMessage:
return BytesParser(policy=policy.default).parsebytes(raw)
@@ -393,6 +426,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
return attachments
def _message_summary_from_headers(*, uid: str, folder: str, headers: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
message = _parse_message(headers.rstrip() + b"\r\n\r\n")
return ImapMailboxMessageSummary(
uid=uid,
folder=folder,
subject=_header_text(message, "Subject"),
from_header=_header_text(message, "From"),
to_header=_header_text(message, "To"),
cc_header=_header_text(message, "Cc"),
date=_header_text(message, "Date"),
message_id=_header_text(message, "Message-ID"),
flags=flags,
size_bytes=size_bytes,
body_preview=None,
attachment_count=0,
)
def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
message = _parse_message(raw)
attachments = _attachment_infos(message)
@@ -434,6 +485,14 @@ def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[s
)
def _parse_fetch_metadata(metadata: str) -> tuple[str | None, list[str], int | None]:
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
flags = flags_match.group(1).split() if flags_match else []
return uid_match.group(1) if uid_match else None, flags, int(size_match.group(1)) if size_match else None
def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
metadata_parts: list[str] = []
raw: bytes | None = None
@@ -445,24 +504,38 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str
else:
metadata_parts.append(_decode_item(item))
metadata = " ".join(part for part in metadata_parts if part)
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
flags = flags_match.group(1).split() if flags_match else []
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
if raw is None:
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
return (
uid_match.group(1) if uid_match else None,
flags,
int(size_match.group(1)) if size_match else None,
raw,
)
return uid, flags, size_bytes, raw
def _select_readonly(client: imaplib.IMAP4, folder: str) -> None:
def _parse_fetch_sequence(metadata: str) -> str | None:
match = re.match(r"\s*(\d+)\s+\(", metadata)
return match.group(1) if match else None
def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -> list[tuple[str | None, str | None, list[str], int | None, bytes]]:
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]] = []
for item in data or []:
if not isinstance(item, tuple) or not isinstance(item[1], bytes):
continue
metadata = _decode_item(item[0])
sequence = _parse_fetch_sequence(metadata)
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
parts.append((sequence, uid, flags, size_bytes, item[1]))
return parts
def _select_readonly(client: imaplib.IMAP4, folder: str) -> int:
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))
except ValueError:
return 0
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
@@ -473,6 +546,50 @@ def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[st
return fetched_uid or str(uid), flags, size_bytes, raw
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
if typ != "OK":
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
if not fetched_uid:
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
return fetched_uid, flags, size_bytes, raw
def _sequence_set(sequences: list[str]) -> str:
sequence_numbers = sorted({int(sequence) for sequence in sequences if str(sequence).isdigit()})
if not sequence_numbers:
return ",".join(str(sequence) for sequence in sequences)
expected_range = list(range(sequence_numbers[0], sequence_numbers[-1] + 1))
if sequence_numbers == expected_range:
return f"{sequence_numbers[0]}:{sequence_numbers[-1]}"
return ",".join(str(sequence) for sequence in sequence_numbers)
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not sequences:
return []
typ, data = client.fetch(_sequence_set(sequences), "(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_sequence: dict[str, ImapMailboxMessageSummary] = {}
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
if not sequence or not fetched_uid:
continue
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
summaries: list[ImapMailboxMessageSummary] = []
for sequence in sequences:
summary = by_sequence.get(str(sequence))
if summary is not None:
summaries.append(summary)
continue
fetched_uid, flags, size_bytes, raw = _fetch_message_by_sequence(client, sequence)
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:
@@ -495,14 +612,16 @@ 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) -> ImapMailboxMessageListResult:
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult:
"""List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX"
limit = max(1, min(limit, 100))
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]
messages = [
_message_summary_from_raw(
uid=str(record.get("id") or ""),
@@ -511,23 +630,16 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None,
)
for record in records[: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))
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit)
client = _open_imap(imap_config)
try:
_select_readonly(client, folder)
typ, data = client.uid("search", None, "ALL")
if typ != "OK":
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True)
uid_bytes = data[0] if data else b""
uids = _decode_item(uid_bytes).split()
messages: list[ImapMailboxMessageSummary] = []
for uid in reversed(uids[-limit:]):
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids))
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)
finally:
try:
client.logout()
@@ -535,6 +647,14 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
pass
def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
if total_count <= 0 or offset >= total_count:
return []
end = total_count - offset
start = max(1, end - limit + 1)
return [str(sequence) for sequence in range(end, start - 1, -1)]
def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult:
"""Fetch one message body using BODY.PEEK in a read-only selected mailbox."""

68
tests/test_imap_parser.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
import unittest
from govoplan_mail.backend.sending.imap import (
_detect_sent_folder,
_extract_mailbox_name,
_paged_descending_sequences,
_parse_fetch_sequence,
_sequence_set,
)
class ImapFolderParserTests(unittest.TestCase):
def test_extracts_quoted_mailbox_after_quoted_delimiter(self):
self.assertEqual(
_extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'),
("Sent Items", {"\\hasnochildren", "\\sent"}),
)
def test_extracts_atom_mailbox(self):
self.assertEqual(
_extract_mailbox_name('(\\HasNoChildren) "/" INBOX'),
("INBOX", {"\\hasnochildren"}),
)
def test_extracts_nil_delimiter_mailbox(self):
self.assertEqual(
_extract_mailbox_name('(\\HasNoChildren) NIL "INBOX.Sent"'),
("INBOX.Sent", {"\\hasnochildren"}),
)
def test_detects_sent_by_flag_before_name(self):
self.assertEqual(
_detect_sent_folder([("Archive", {"\\sent"}), ("Sent", set())]),
"Archive",
)
def test_detects_common_sent_name(self):
self.assertEqual(
_detect_sent_folder([("INBOX", set()), ("Gesendet", set())]),
"Gesendet",
)
class ImapMessagePaginationTests(unittest.TestCase):
def test_paginates_sequence_numbers_newest_first(self):
self.assertEqual(
_paged_descending_sequences(120, offset=0, limit=5),
["120", "119", "118", "117", "116"],
)
self.assertEqual(_paged_descending_sequences(120, offset=118, limit=5), ["2", "1"])
self.assertEqual(_paged_descending_sequences(120, offset=120, limit=5), [])
def test_compacts_contiguous_sequence_sets(self):
self.assertEqual(_sequence_set(["5", "4", "3"]), "3:5")
self.assertEqual(_sequence_set(["9", "7", "5"]), "5,7,9")
def test_extracts_fetch_sequence_number(self):
self.assertEqual(
_parse_fetch_sequence("42 (UID 123 FLAGS (\\Seen) RFC822.SIZE 100)"),
"42",
)
self.assertIsNone(_parse_fetch_sequence("UID 123 FLAGS ()"))
if __name__ == "__main__":
unittest.main()

View File

@@ -24,5 +24,11 @@
"@govoplan/core-webui": {
"optional": true
}
},
"scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-policy-validation.test.js"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

View File

@@ -14,10 +14,19 @@ export type MailSmtpTestPayload = {
};
export type MailImapTestPayload = MailSmtpTestPayload & {
enabled?: boolean;
sent_folder?: string | null;
};
export type MailTransportCredentialsPayload = {
username?: string | null;
password?: string | null;
};
export type MailServerProfileCredentialsPayload = {
smtp?: MailTransportCredentialsPayload;
imap?: MailTransportCredentialsPayload;
};
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
@@ -31,6 +40,8 @@ export type MailConnectionTestResponse = {
export type MailImapFolderResponse = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailImapFolderListResponse = {
@@ -59,6 +70,7 @@ export type MailMailboxMessageSummary = {
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
date?: string | null;
message_id?: string | null;
flags?: string[];
@@ -81,6 +93,8 @@ export type MailMailboxMessageListResponse = {
port?: number | null;
security?: MailSecurity | string | null;
total_count?: number;
offset?: number;
limit?: number;
messages: MailMailboxMessageSummary[];
};
@@ -104,6 +118,7 @@ export type MailServerProfile = {
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
@@ -123,9 +138,30 @@ export const mailProfilePatternKeys = [
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"allow_campaign_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
] as const;
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePolicy = {
@@ -137,6 +173,7 @@ export type MailProfilePolicy = {
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
allow_lower_level_limits?: MailProfilePolicyLimitPermissions | null;
};
export type PolicySourceStep = {
@@ -144,6 +181,7 @@ export type PolicySourceStep = {
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: MailProfilePolicy | null;
};
export type MailProfilePolicyResponse = {
@@ -165,6 +203,7 @@ export type MailServerProfilePayload = {
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
@@ -244,9 +283,10 @@ export async function listMailboxMessages(
settings: ApiSettings,
profileId: string,
folder = "INBOX",
limit = 50
limit = 50,
offset = 0
): Promise<MailMailboxMessageListResponse> {
const params = new URLSearchParams({ folder, limit: String(limit) });
const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) });
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
}

View File

@@ -1,2 +0,0 @@
import { Button } from "@govoplan/core-webui";
export default Button;

View File

@@ -1,2 +0,0 @@
import { Card } from "@govoplan/core-webui";
export default Card;

View File

@@ -1,2 +0,0 @@
import { ConfirmDialog } from "@govoplan/core-webui";
export default ConfirmDialog;

View File

@@ -1,2 +0,0 @@
import { Dialog } from "@govoplan/core-webui";
export default Dialog;

View File

@@ -1,2 +0,0 @@
import { DismissibleAlert } from "@govoplan/core-webui";
export default DismissibleAlert;

View File

@@ -1,2 +0,0 @@
import { FormField } from "@govoplan/core-webui";
export default FormField;

View File

@@ -1,2 +0,0 @@
import { LoadingFrame } from "@govoplan/core-webui";
export default LoadingFrame;

View File

@@ -1,2 +0,0 @@
import { LoadingIndicator } from "@govoplan/core-webui";
export default LoadingIndicator;

View File

@@ -1,2 +0,0 @@
import { MetricCard } from "@govoplan/core-webui";
export default MetricCard;

View File

@@ -1,2 +0,0 @@
import { PageTitle } from "@govoplan/core-webui";
export default PageTitle;

View File

@@ -1,2 +0,0 @@
import { StatusBadge } from "@govoplan/core-webui";
export default StatusBadge;

View File

@@ -1,2 +0,0 @@
import { Stepper } from "@govoplan/core-webui";
export default Stepper;

View File

@@ -1,2 +0,0 @@
import { ToggleSwitch } from "@govoplan/core-webui";
export default ToggleSwitch;

View File

@@ -1,2 +0,0 @@
import { EmailAddressInput } from "@govoplan/core-webui";
export default EmailAddressInput;

View File

@@ -1,2 +0,0 @@
import { FieldLabel } from "@govoplan/core-webui";
export default FieldLabel;

View File

@@ -1,2 +0,0 @@
import { InlineHelp } from "@govoplan/core-webui";
export default InlineHelp;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import {
Button,
DismissibleAlert,
@@ -19,14 +19,7 @@ import {
type MailMailboxMessageSummary,
type MailServerProfile
} from "../../api/mail";
type MailFolderNode = {
id: string;
label: string;
folderName: string | null;
flags: string[];
children: MailFolderNode[];
};
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
@@ -37,13 +30,21 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [messagePage, setMessagePage] = useState(1);
const [messagePageSize, setMessagePageSize] = useState(50);
const [messageQuery, setMessageQuery] = useState("");
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
const [selectedMessageKeyState, setSelectedMessageKeyState] = useState("");
const [pendingMessageKey, setPendingMessageKey] = useState("");
const [loadingProfiles, setLoadingProfiles] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(false);
const [error, setError] = useState("");
const [folderError, setFolderError] = useState("");
const [messageError, setMessageError] = useState("");
const [detailError, setDetailError] = useState("");
const selectedMessageKeyRef = useRef("");
const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0);
@@ -52,19 +53,23 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : pendingMessageKey;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.";
const messageEmptyText = !selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : "No messages in this folder.";
const previewEmptyText = loadingMessage ? "Loading message..." : "Select a message to inspect its content.";
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...";
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => { selectedMessageKeyRef.current = selectedMessageKeyState; }, [selectedMessageKeyState]);
useEffect(() => { if (messagePage > messagePageCount) setMessagePage(messagePageCount); }, [messagePage, messagePageCount]);
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder); }, [foldersReady, selectedProfileId, selectedFolder]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize); }, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
@@ -90,6 +95,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
}
} catch (err) {
@@ -107,7 +113,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setLoadingFolders(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
setFolderError("");
setMessageError("");
setDetailError("");
setError("");
try {
const response = await listMailboxFolders(settings, profileId);
@@ -124,40 +136,51 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setFoldersLoadedForProfile(profileId);
} catch (err) {
if (requestId !== folderRequestRef.current) return;
setFolderError(errorText(err));
setError(errorText(err));
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
} finally {
if (requestId === folderRequestRef.current) setLoadingFolders(false);
}
}
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize) {
if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1;
const offset = (Math.max(1, page) - 1) * pageSize;
setLoadingMessages(true);
setMessageTotalCount(null);
setSelectedMessage(null);
setPendingMessageKey("");
setMessageError("");
setDetailError("");
setError("");
try {
const response = await listMailboxMessages(settings, profileId, folder, 50);
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset);
if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length;
setMessages(loaded);
setMessageTotalCount(response.total_count ?? loaded.length);
setSelectedMessage(null);
setMessageTotalCount(total);
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
}
} catch (err) {
if (requestId !== messageListRequestRef.current) return;
setError(errorText(err));
const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
} finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
@@ -168,18 +191,25 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (!selectedProfileId) return;
const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current;
setPendingMessageKey(mailboxMessageKey(folderName, message.uid));
const nextKey = mailboxMessageKey(folderName, message.uid);
setSelectedMessageKeyState(nextKey);
selectedMessageKeyRef.current = nextKey;
setPendingMessageKey(nextKey);
setSelectedMessage(null);
setDetailError("");
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
if (requestId !== messageDetailRequestRef.current) return;
setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
setPendingMessageKey("");
} catch (err) {
if (requestId !== messageDetailRequestRef.current) return;
setError(errorText(err));
const messageText = errorText(err);
setDetailError(messageText);
setError(messageText);
setPendingMessageKey("");
} finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
@@ -195,7 +225,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setExpandedFolders(new Set());
setLoadingFolders(false);
@@ -205,8 +238,16 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName);
else setSelectedFolder(node.folderName);
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);
else {
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setDetailError("");
}
return;
}
toggleFolderNode(node);
@@ -221,6 +262,20 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
});
}
function setFolderMessageCount(folderName: string, count: number) {
setFolders((current) => current.map((folder) => folder.name === folderName ? { ...folder, message_count: count } : folder));
}
function changeMessagePage(page: number) {
const nextPage = Math.min(messagePageCount, Math.max(1, page));
if (nextPage !== messagePage) setMessagePage(nextPage);
}
function changeMessagePageSize(pageSize: number) {
setMessagePageSize(pageSize);
setMessagePage(1);
}
return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
@@ -242,12 +297,12 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
disabled={shellBusy}
renderNodeContent={(node) => {
const flagText = folderFlagLabel(node.flags);
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null;
const showCount = foldersReady && node.messageCount !== null;
return (
<span className="mailbox-tree-node-label">
<span className="mailbox-tree-node-main">
<span>{node.label}</span>
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>}
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
</span>
{flagText && <small>{flagText}</small>}
</span>
@@ -262,22 +317,22 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
<label className="mailbox-profile-field">
<span>IMAP profile</span>
<select value={selectedProfileId} disabled={shellBusy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
{imapProfiles.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>
<div className="mailbox-toolbar-actions">
<Button onClick={() => void loadProfiles()} disabled={shellBusy} title="Reload IMAP profiles">
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="Reload IMAP profiles">
<RefreshCw size={16} aria-hidden="true" />
Profiles
</Button>
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || shellBusy} title="Refresh mailbox folders">
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || loadingFolders} title="Refresh mailbox folders">
<RefreshCw size={16} aria-hidden="true" />
Folders
</Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || shellBusy} title="Refresh messages in the current folder">
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="Refresh messages in the current folder">
<RefreshCw size={16} aria-hidden="true" />
Messages
</Button>
@@ -289,9 +344,18 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<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>}
</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>}
</div>
@@ -305,8 +369,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-list-drop-target mailbox-message-scroll">
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>}
{messages.map((message) => {
{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);
const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey;
@@ -343,6 +407,14 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
})}
</div>
</div>
<MailboxPagination
page={messagePage}
pageSize={messagePageSize}
totalRows={messageTotalCount ?? 0}
disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize}
/>
</section>
<section className="mailbox-preview-panel" aria-label="Message preview">
@@ -354,6 +426,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{ 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: "-" }) }
] : []}
bodyText={selectedMessage?.body_text}
@@ -380,81 +453,49 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
);
}
function buildMailboxFolderTree(folders: MailImapFolderResponse[]): MailFolderNode[] {
const roots: MailFolderNode[] = [];
const byId = new Map<string, MailFolderNode>();
function ensureNode(parts: string[], index: number): MailFolderNode | null {
if (index < 0 || index >= parts.length) return null;
const id = parts.slice(0, index + 1).join("/");
const existing = byId.get(id);
if (existing) return existing;
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], children: [] };
byId.set(id, node);
if (index === 0) {
roots.push(node);
} else {
ensureNode(parts, index - 1)?.children.push(node);
}
return node;
}
for (const folder of folders) {
const parts = folderParts(folder.name);
const node = ensureNode(parts, parts.length - 1);
if (node) {
node.folderName = folder.name;
node.flags = folder.flags ?? [];
}
}
sortFolderNodes(roots);
return roots;
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 = [25, 50, 100];
return (
<div className="data-grid-pagination mailbox-pagination" aria-label="Mailbox message pagination">
<div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div>
<label className="data-grid-page-size">
<span>Rows per page</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>
</div>
</div>
);
}
function folderParts(folderName: string): string[] {
const normalized = folderName.trim();
if (!normalized) return [];
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
return [normalized];
}
function sortFolderNodes(nodes: MailFolderNode[]) {
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
nodes.forEach((node) => sortFolderNodes(node.children));
}
function folderSortKey(label: string): string {
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
}
function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
for (const node of nodes) {
if (node.folderName === folderName) return node.id;
const child = findFolderNodeId(node.children, folderName);
if (child) return child;
}
return null;
}
function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
for (const node of nodes) {
const path = [...parents, node.id];
if (node.folderName === folderName) return path;
const child = folderAncestorIds(node.children, folderName, path);
if (child.length > 0) return child;
}
return [];
}
function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
}
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return messages;
return messages.filter((message) => [
message.subject,
message.from_header,
message.to_header,
message.cc_header,
message.date,
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";

View File

@@ -0,0 +1,120 @@
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePolicy = {
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
};
export type MailPolicyValidationInput = {
smtpHost?: string | null;
imapHost?: string | null;
envelopeSender?: string | null;
fromHeader?: string | null;
recipientDomains?: Array<string | null | undefined> | null;
};
export type MailPolicyValidationMessage = {
key: MailProfilePatternKey;
label: string;
value: string;
message: string;
severity?: "warning" | "danger";
};
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"
};
type ValueCheck = {
key: MailProfilePatternKey;
value: string;
};
export function validateMailPolicy(
policy: MailProfilePolicy | null | undefined,
input: MailPolicyValidationInput,
): MailPolicyValidationMessage[] {
if (!policy) return [];
const checks: ValueCheck[] = [
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
{ 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 }))
];
return checks.flatMap(({ key, value }) => {
const result = mailPolicyValueAllowed(policy, key, value);
if (result.allowed) return [];
const label = patternLabels[key];
return [{
key,
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.`
}];
});
}
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 } {
const value = normalizePolicyValue(key, rawValue);
if (!value || !policy) return { allowed: true, value };
const deniedBy = patternsFor(policy.blacklist, key).find((pattern) => wildcardPatternMatches(pattern, value));
if (deniedBy) return { allowed: false, value, reason: "blacklist", pattern: deniedBy };
const allowPatterns = meaningfulAllowPatterns(patternsFor(policy.whitelist, key));
if (allowPatterns.length > 0 && !allowPatterns.some((pattern) => wildcardPatternMatches(pattern, value))) {
return { allowed: false, value, reason: "whitelist" };
}
return { allowed: true, value };
}
export function wildcardPatternMatches(pattern: string, rawValue: string): boolean {
const normalizedPattern = pattern.trim();
const value = rawValue.trim();
if (!normalizedPattern || !value) return false;
if (normalizedPattern === "*") return true;
const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const regex = `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`;
return new RegExp(regex, "i").test(value);
}
function patternsFor(
rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"],
key: MailProfilePatternKey,
): string[] {
return (rules?.[key] ?? []).map((pattern) => String(pattern).trim()).filter(Boolean);
}
function meaningfulAllowPatterns(patterns: string[]): string[] {
return patterns.filter((pattern) => pattern !== "*");
}
function normalizePolicyValue(key: MailProfilePatternKey, value: string | null | undefined): string {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "";
if (key === "recipient_domains") return normalizeDomain(trimmed);
return trimmed;
}
function normalizeDomain(value: string | null | undefined): string {
const trimmed = String(value ?? "").trim().toLowerCase();
if (!trimmed) return "";
const emailDomain = trimmed.includes("@") ? trimmed.split("@").pop() ?? "" : trimmed;
return emailDomain.replace(/^@+/, "");
}

View File

@@ -0,0 +1,88 @@
export type MailboxFolderInfo = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailFolderNode = {
id: string;
label: string;
folderName: string | null;
flags: string[];
messageCount: number | null;
unseenCount: number | null;
children: MailFolderNode[];
};
export function buildMailboxFolderTree(folders: MailboxFolderInfo[]): MailFolderNode[] {
const roots: MailFolderNode[] = [];
const byId = new Map<string, MailFolderNode>();
function ensureNode(parts: string[], index: number): MailFolderNode | null {
if (index < 0 || index >= parts.length) return null;
const id = parts.slice(0, index + 1).join("/");
const existing = byId.get(id);
if (existing) return existing;
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], messageCount: null, unseenCount: null, children: [] };
byId.set(id, node);
if (index === 0) {
roots.push(node);
} else {
ensureNode(parts, index - 1)?.children.push(node);
}
return node;
}
for (const folder of folders) {
const parts = folderParts(folder.name);
const node = ensureNode(parts, parts.length - 1);
if (node) {
node.folderName = folder.name;
node.flags = folder.flags ?? [];
node.messageCount = typeof folder.message_count === "number" ? folder.message_count : null;
node.unseenCount = typeof folder.unseen_count === "number" ? folder.unseen_count : null;
}
}
sortFolderNodes(roots);
return roots;
}
export function folderParts(folderName: string): string[] {
const normalized = folderName.trim();
if (!normalized) return [];
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
return [normalized];
}
export function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
for (const node of nodes) {
if (node.folderName === folderName) return node.id;
const child = findFolderNodeId(node.children, folderName);
if (child) return child;
}
return null;
}
export function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
for (const node of nodes) {
const path = [...parents, node.id];
if (node.folderName === folderName) return path;
const child = folderAncestorIds(node.children, folderName, path);
if (child.length > 0) return child;
}
return [];
}
function sortFolderNodes(nodes: MailFolderNode[]) {
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
nodes.forEach((node) => sortFolderNodes(node.children));
}
function folderSortKey(label: string): string {
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
}

View File

@@ -1,5 +1,7 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"];
@@ -12,7 +14,13 @@ export const mailModule: PlatformWebModule = {
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [
{ 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
}
};
export default mailModule;

View File

@@ -123,6 +123,42 @@
overflow-wrap: anywhere;
}
.mail-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr);
}
.mail-policy-table.with-effective-column .mail-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr) minmax(220px, 1fr);
}
.mail-policy-table.with-allow-column .mail-policy-row {
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(150px, .55fr);
}
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row {
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(210px, .95fr) minmax(150px, .55fr);
}
.mail-policy-pattern-row {
grid-template-columns: minmax(190px, .8fr) minmax(220px, 1fr) minmax(220px, 1fr);
align-items: start;
}
.mail-policy-pattern-table.with-allow-column .mail-policy-pattern-row {
grid-template-columns: minmax(170px, .7fr) minmax(200px, 1fr) minmax(200px, 1fr) minmax(165px, .6fr);
}
.mail-policy-pattern-limits {
display: grid;
gap: 8px;
min-width: 0;
}
.mail-policy-lower-cell .toggle-switch-row,
.mail-policy-pattern-limits .toggle-switch-row {
margin: 0;
}
.mail-policy-pattern-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -179,10 +215,19 @@
@media (max-width: 900px) {
.admin-form-grid.three-columns,
.mail-policy-pattern-grid,
.mail-policy-effective-grid {
.mail-policy-effective-grid,
.mail-policy-row,
.mail-policy-table.with-effective-column .mail-policy-row,
.mail-policy-table.with-allow-column .mail-policy-row,
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row,
.mail-policy-pattern-row {
grid-template-columns: 1fr;
}
.mail-policy-row-header {
display: none;
}
.mail-credential-inheritance-row {
grid-template-columns: 1fr;
@@ -539,6 +584,62 @@
min-width: 660px;
}
.mailbox-filter-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 0;
}
.mailbox-search-field {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: min(430px, 100%);
min-height: 34px;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
}
.mailbox-search-field input {
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
padding: 6px 0;
}
.mailbox-search-field button {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--radius-xs, 5px);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.mailbox-search-field button:hover,
.mailbox-search-field button:focus-visible {
background: var(--surface-subtle);
color: var(--text-strong);
}
.mailbox-error-state {
color: var(--danger-text, #8f2e2e);
}
.mailbox-pagination {
flex: 0 0 auto;
}
.mailbox-message-head,
.mailbox-message-row {
grid-template-columns: minmax(260px, 1fr) 190px 130px;

View File

@@ -0,0 +1,46 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
}
import { mailPolicyValueAllowed, validateMailPolicy, wildcardPatternMatches } from "../src/features/mail/mailPolicyValidation";
assertEqual(wildcardPatternMatches("*.example.org", "smtp.example.org"), true);
assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp1.example.org"), true);
assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp12.example.org"), false);
const policy = {
whitelist: {
smtp_hosts: ["smtp.allowed.test"],
from_headers: ["*@allowed.test"],
recipient_domains: ["allowed.test"]
},
blacklist: {
smtp_hosts: ["smtp.blocked.test"],
envelope_senders: ["blocked@*"]
}
};
assertDeepEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.allowed.test"), { allowed: true, value: "smtp.allowed.test" });
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.blocked.test").allowed, false, "blacklist wins for SMTP host");
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.other.test").allowed, false, "whitelist blocks unknown SMTP host");
const messages = validateMailPolicy(policy, {
smtpHost: "smtp.other.test",
envelopeSender: "blocked@allowed.test",
fromHeader: "sender@other.test",
recipientDomains: ["allowed.test", "denied.test", "user@denied.test"]
});
assert(messages.some((item) => item.key === "smtp_hosts" && item.value === "smtp.other.test"));
assert(messages.some((item) => item.key === "envelope_senders" && item.value === "blocked@allowed.test"));
assert(messages.some((item) => item.key === "from_headers" && item.value === "sender@other.test"));
assertEqual(messages.filter((item) => item.key === "recipient_domains" && item.value === "denied.test").length, 1, "recipient domains are normalized and de-duplicated");

View File

@@ -0,0 +1,30 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
}
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, folderParts } from "../src/features/mail/mailboxFolders";
assertDeepEqual(folderParts("INBOX.Sent.Archive"), ["INBOX", "Sent", "Archive"]);
assertDeepEqual(folderParts("Projects/2026/Inbox"), ["Projects", "2026", "Inbox"]);
const tree = buildMailboxFolderTree([
{ name: "Archive/2026", flags: [] },
{ name: "INBOX", flags: [] },
{ name: "INBOX.Sent", flags: ["\\Sent"] },
{ name: "Projects/2026/Inbox", flags: [] }
]);
assertEqual(tree[0].id, "INBOX", "INBOX sorts first");
assertEqual(findFolderNodeId(tree, "INBOX.Sent"), "INBOX/Sent");
assertDeepEqual(folderAncestorIds(tree, "Projects/2026/Inbox"), ["Projects", "Projects/2026", "Projects/2026/Inbox"]);
assertEqual(tree.find((node) => node.id === "INBOX")?.children[0]?.folderName, "INBOX.Sent");

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020",
"DOM"
],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".mail-test-build",
"rootDir": "."
},
"include": [
"tests/mailbox-folders.test.ts",
"tests/mail-policy-validation.test.ts",
"src/features/mail/mailboxFolders.ts",
"src/features/mail/mailPolicyValidation.ts"
]
}