4 Commits

Author SHA1 Message Date
3743b23613 Release v0.1.4 2026-07-02 14:59:52 +02:00
e6e243c14d Release v0.1.3 2026-06-26 01:39:19 +02:00
3d82d7b32d Release v0.1.2 2026-06-25 19:58:20 +02:00
3cd8c45c42 Fix WebUI release peer metadata 2026-06-24 20:17:42 +02:00
41 changed files with 1928 additions and 526 deletions

3
.gitignore vendored
View File

@@ -326,3 +326,6 @@ cython_debug/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.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. 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 ## Development
Install through the core environment: Install through the core environment:

View File

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

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-mail" name = "govoplan-mail"
version = "0.1.0" version = "0.1.4"
description = "GovOPlaN mail module with backend and WebUI integration." description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.0", "govoplan-core>=0.1.4",
"pydantic>=2,<3", "pydantic>=2,<3",
"redis>=5,<6", "redis>=5,<6",
"SQLAlchemy>=2,<3", "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 __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 __all__ = [
"ImapConfig",
"ImapServerConfig",
class StrictModel(BaseModel): "SmtpConfig",
model_config = ConfigDict(extra="forbid", populate_by_name=True) "SmtpServerConfig",
"StrictModel",
"TransportCredentials",
class TransportSecurity(StrEnum): "TransportSecurity",
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)

View File

@@ -29,8 +29,10 @@ class MailServerProfile(Base, TimestampMixin):
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) 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_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) smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
imap_username: Mapped[str | None] = mapped_column(String(320))
imap_password_encrypted: Mapped[str | None] = mapped_column(Text) imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("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 sqlalchemy.orm import Session
from govoplan_core.admin.governance import get_system_settings 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_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.db.models import MailServerProfile
from govoplan_mail.backend.config import ImapConfig, SmtpConfig from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
@@ -25,20 +25,47 @@ MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy"
PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"} PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"}
PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "envelope_senders", "from_headers", "recipient_domains") 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} 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) @dataclass(slots=True)
class EffectiveCredentialPolicy: class EffectiveCredentialPolicy:
inherit: bool = True inherit: bool = True
allow_override: bool = True
explicit_inherit: bool = False explicit_inherit: bool = False
inherit_source: str | None = None inherit_source: str | None = None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { return {"inherit": self.inherit}
"inherit": self.inherit,
"allow_override": self.allow_override,
}
@dataclass(slots=True) @dataclass(slots=True)
@@ -51,6 +78,7 @@ class EffectiveMailProfilePolicy:
imap_credentials: EffectiveCredentialPolicy = field(default_factory=EffectiveCredentialPolicy) 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}) 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}) 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) source_policies: list[dict[str, Any]] = field(default_factory=list)
def profile_id_allowed(self, profile_id: str) -> bool: def profile_id_allowed(self, profile_id: str) -> bool:
@@ -87,6 +115,7 @@ class EffectiveMailProfilePolicy:
"imap_credentials": self.imap_credentials.as_dict(), "imap_credentials": self.imap_credentials.as_dict(),
"whitelist": whitelist, "whitelist": whitelist,
"blacklist": blacklist, "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" 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") payload = config.model_dump(mode="json")
username_was_supplied = "username" in config.model_fields_set
password_was_supplied = "password" in config.model_fields_set password_was_supplied = "password" in config.model_fields_set
username = payload.pop("username", None)
password = payload.pop("password", 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: 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") inherit = data.get("inherit")
if inherit is None: if inherit is None:
inherit = data.get("inherit_credentials") inherit = data.get("inherit_credentials")
allow_override = data.get("allow_override") return {"inherit": inherit if isinstance(inherit, bool) else None}
return {
"inherit": inherit if isinstance(inherit, bool) else None,
"allow_override": allow_override if isinstance(allow_override, 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]: 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")), "imap_credentials": _normalize_credential_policy(data.get("imap_credentials")),
"whitelist": whitelist, "whitelist": whitelist,
"blacklist": blacklist, "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"): for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
value = data.get(key) 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, dict):
if isinstance(credential.get("inherit"), bool): if isinstance(credential.get("inherit"), bool):
fields.append(f"{key}.inherit") fields.append(f"{key}.inherit")
if isinstance(credential.get("allow_override"), bool):
fields.append(f"{key}.allow_override")
for kind in ("whitelist", "blacklist"): for kind in ("whitelist", "blacklist"):
rules = policy.get(kind) or {} rules = policy.get(kind) or {}
if isinstance(rules, dict): if isinstance(rules, dict):
for key in PROFILE_PATTERN_KEYS: for key in PROFILE_PATTERN_KEYS:
if _clean_string_list(rules.get(key, [])): if _clean_string_list(rules.get(key, [])):
fields.append(f"{kind}.{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: if baseline and not fields:
fields.append("defaults") fields.append("defaults")
return fields 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]: def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
return { return {
"scope_type": source, "scope_type": source,
"scope_id": source_id, "scope_id": source_id,
"label": label, "label": label,
"applied_fields": _mail_policy_source_fields(policy, baseline=baseline), "applied_fields": _mail_policy_source_fields(policy, baseline=baseline),
"policy": _mail_policy_source_policy(policy, baseline=baseline),
} }
def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None:
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") inherit = data.get("inherit")
if isinstance(inherit, bool): if isinstance(inherit, bool) and lower_limits.get(inherit_limit_key, True):
if current.allow_override:
current.inherit = inherit current.inherit = inherit
current.explicit_inherit = True current.explicit_inherit = True
current.inherit_source = source 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_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str, source_id: str | None = None, label: str | None = None, baseline: bool = False) -> None: def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str, source_id: str | None = None, label: str | None = None, baseline: bool = False) -> None:
normalized = normalize_mail_profile_policy(data) normalized = normalize_mail_profile_policy(data)
policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline)) 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 []) 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)) 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 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 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 policy.allow_campaign_profiles = False
_merge_credential_policy(policy.smtp_credentials, normalized.get("smtp_credentials") or {}, source=source) _merge_credential_policy(
_merge_credential_policy(policy.imap_credentials, normalized.get("imap_credentials") or {}, source=source) 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 {} whitelist = normalized.get("whitelist") or {}
blacklist = normalized.get("blacklist") or {} blacklist = normalized.get("blacklist") or {}
for key in PROFILE_PATTERN_KEYS: for key in PROFILE_PATTERN_KEYS:
allow_patterns = _meaningful_allow_patterns(whitelist.get(key, [])) 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) policy.whitelist_groups.setdefault(key, []).append(allow_patterns)
deny_patterns = _clean_string_list(blacklist.get(key, [])) 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) 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( def _owner_context(
@@ -282,10 +373,10 @@ def _owner_context(
campaign_id: str | None = None, campaign_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> tuple[Campaign | None, str | None, str | None]: ) -> tuple[Any | None, str | None, str | None]:
campaign = None campaign = None
if campaign_id: 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: if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy") raise MailProfileError("Campaign not found for mail-server profile policy")
owner_user_id = campaign.owner_user_id owner_user_id = campaign.owner_user_id
@@ -329,6 +420,38 @@ def effective_mail_profile_policy(
return 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( def parent_mail_profile_policy(
session: Session, session: Session,
*, *,
@@ -353,7 +476,7 @@ def parent_mail_profile_policy(
if clean_scope != "campaign" or not scope_id: if clean_scope != "campaign" or not scope_id:
return policy return policy
campaign = session.get(Campaign, scope_id) campaign = session.get(_campaign_model(), scope_id)
if campaign is None or campaign.tenant_id != tenant_id: if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found") raise MailProfileError("Campaign policy not found")
if campaign.owner_user_id: if campaign.owner_user_id:
@@ -470,7 +593,7 @@ def _scope_tuple_for_create(
return tenant_id, clean_scope, clean_scope_id return tenant_id, clean_scope, clean_scope_id
if not clean_scope_id: if not clean_scope_id:
raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id") raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id")
campaign = session.get(Campaign, clean_scope_id) campaign = session.get(_campaign_model(), clean_scope_id)
if campaign is None or campaign.tenant_id != tenant_id: if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign scope is not part of the active tenant") raise MailProfileError("Campaign scope is not part of the active tenant")
return tenant_id, clean_scope, clean_scope_id return tenant_id, clean_scope, clean_scope_id
@@ -524,7 +647,7 @@ def _profile_visible_filter(tenant_id: str):
return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id) return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id)
def _context_profile_clauses(campaign: Campaign, policy: EffectiveMailProfilePolicy) -> list[Any]: def _context_profile_clauses(campaign: Any, policy: EffectiveMailProfilePolicy) -> list[Any]:
clauses: list[Any] = [ clauses: list[Any] = [
MailServerProfile.scope_type == "system", MailServerProfile.scope_type == "system",
and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"), and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"),
@@ -551,7 +674,7 @@ def _profile_allowed_for_context(
*, *,
tenant_id: str, tenant_id: str,
policy: EffectiveMailProfilePolicy, policy: EffectiveMailProfilePolicy,
campaign: Campaign | None = None, campaign: Any | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> bool: ) -> bool:
@@ -588,7 +711,7 @@ def list_mail_server_profiles(
campaign_id: str | None = None, campaign_id: str | None = None,
) -> list[MailServerProfile]: ) -> list[MailServerProfile]:
if campaign_id: if campaign_id:
campaign = session.get(Campaign, campaign_id) campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id: if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profiles") raise MailProfileError("Campaign not found for mail-server profiles")
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
@@ -639,7 +762,7 @@ def ensure_mail_profile_allowed_for_campaign(
profile_id: str, profile_id: str,
require_active: bool = True, require_active: bool = True,
) -> MailServerProfile: ) -> MailServerProfile:
campaign = session.get(Campaign, campaign_id) campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id: if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy") raise MailProfileError("Campaign not found for mail-server profile policy")
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active) profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active)
@@ -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 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: def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool:
credential_policy = _credential_policy_for_protocol(policy, protocol) credential_policy = _credential_policy_for_protocol(policy, protocol)
legacy_key = f"inherit_{protocol}_credentials" 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 _legacy_profile_credentials_inherited(server, protocol)
return credential_policy.inherit return credential_policy.inherit
@@ -718,7 +849,7 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p
legacy_key = f"inherit_{protocol}_credentials" legacy_key = f"inherit_{protocol}_credentials"
if legacy_key in server: if legacy_key in server:
legacy_inherit = _legacy_profile_credentials_inherited(server, protocol) 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") raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy")
inherited = profile_credentials_inherited_for_server(policy, server, protocol) inherited = profile_credentials_inherited_for_server(policy, server, protocol)
username, password = _transport_credentials_from_server(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") raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
def _inline_transport_configured(value: Any) -> bool: def _inline_transport_configured(server: dict[str, Any], protocol: str) -> bool:
if not isinstance(value, dict): value = server.get(protocol) if isinstance(server.get(protocol), dict) else {}
return False credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
if value.get("enabled") is True: credential_value = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
return True for key in ("host",):
for key in ("host", "username", "password"):
if str(value.get(key) or "").strip(): if str(value.get(key) or "").strip():
return True return True
for key in ("username", "password"):
if str(credential_value.get(key) or value.get(key) or "").strip():
return True
return False 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 imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
if campaign_id: if campaign_id:
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if not 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") raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
_assert_transport_values_allowed(policy, smtp, imap) _assert_transport_values_allowed(policy, smtp, imap)
return return
@@ -834,11 +967,12 @@ def create_mail_server_profile(
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap) assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap)
clean_slug = slugify_profile_name(slug or clean_name) 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) _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_payload = None
imap_username = None
imap_password = None imap_password = None
if imap is not None: if imap is not None:
imap_payload, imap_password, _ = _transport_payload(imap) imap_payload, imap_username, imap_password, _, _ = _transport_payload(imap)
profile = MailServerProfile( profile = MailServerProfile(
tenant_id=profile_tenant_id, tenant_id=profile_tenant_id,
scope_type=clean_scope_type, scope_type=clean_scope_type,
@@ -848,8 +982,10 @@ def create_mail_server_profile(
description=description, description=description,
is_active=is_active, is_active=is_active,
smtp_config=smtp_payload, smtp_config=smtp_payload,
smtp_username=smtp_username,
smtp_password_encrypted=encrypt_secret(smtp_password), smtp_password_encrypted=encrypt_secret(smtp_password),
imap_config=imap_payload, imap_config=imap_payload,
imap_username=imap_username,
imap_password_encrypted=encrypt_secret(imap_password), imap_password_encrypted=encrypt_secret(imap_password),
created_by_user_id=user_id, created_by_user_id=user_id,
updated_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: if is_active is not None:
profile.is_active = is_active 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: if clear_imap:
next_imap = None next_imap = None
elif imap is not None: elif imap is not None:
next_imap = imap next_imap = imap
elif profile.imap_config: 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: else:
next_imap = None 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) assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap)
if smtp is not None: 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 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) profile.smtp_password_encrypted = encrypt_secret(smtp_password)
if clear_imap: if clear_imap:
profile.imap_config = None profile.imap_config = None
profile.imap_username = None
profile.imap_password_encrypted = None profile.imap_password_encrypted = None
elif imap is not 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 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.imap_password_encrypted = encrypt_secret(imap_password)
profile.updated_by_user_id = user_id profile.updated_by_user_id = user_id
session.add(profile) session.add(profile)
@@ -933,8 +1074,18 @@ def update_mail_server_profile(
return 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: def smtp_config_from_profile(profile: MailServerProfile) -> SmtpConfig:
payload = dict(profile.smtp_config or {}) payload = dict(profile.smtp_config or {})
payload.pop("username", None)
payload["username"] = _profile_username(profile, "smtp")
payload["password"] = decrypt_secret(profile.smtp_password_encrypted) payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
return SmtpConfig.model_validate(payload) return SmtpConfig.model_validate(payload)
@@ -943,6 +1094,9 @@ def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None:
if not profile.imap_config: if not profile.imap_config:
return None return None
payload = dict(profile.imap_config or {}) 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) payload["password"] = decrypt_secret(profile.imap_password_encrypted)
return ImapConfig.model_validate(payload) 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]: def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]:
config = server.get(protocol) credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
if not isinstance(config, dict): config = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else None
return None, None legacy_config = server.get(protocol) if isinstance(server.get(protocol), dict) else None
username = config.get("username") username = config.get("username") if config is not None else None
password = config.get("password") 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) 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 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]: def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
return { return {
"id": profile.id, "id": profile.id,
@@ -977,8 +1143,12 @@ def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
"slug": profile.slug, "slug": profile.slug,
"description": profile.description, "description": profile.description,
"is_active": profile.is_active, "is_active": profile.is_active,
"smtp": dict(profile.smtp_config or {}), "smtp": _server_config_payload(profile.smtp_config),
"imap": dict(profile.imap_config or {}) if profile.imap_config else None, "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), "smtp_password_configured": bool(profile.smtp_password_encrypted),
"imap_password_configured": bool(profile.imap_password_encrypted), "imap_password_configured": bool(profile.imap_password_encrypted),
"created_at": profile.created_at, "created_at": profile.created_at,
@@ -1013,22 +1183,36 @@ def get_mail_profile_policy(
if group is None or group.tenant_id != tenant_id: if group is None or group.tenant_id != tenant_id:
raise MailProfileError("Group policy not found") raise MailProfileError("Group policy not found")
return _policy_from_attr(group.mail_profile_policy) 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: if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found") raise MailProfileError("Campaign policy not found")
return _policy_from_attr(campaign.mail_profile_policy) return _policy_from_attr(campaign.mail_profile_policy)
def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None: 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"): for protocol in ("smtp", "imap"):
local = normalized.get(f"{protocol}_credentials") or {} local = normalized.get(f"{protocol}_credentials") or {}
parent_credentials = _credential_policy_for_protocol(parent, protocol)
local_inherit = local.get("inherit") 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") raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by an ancestor policy")
local_allow_override = local.get("allow_override") local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if local_allow_override is True and not parent_credentials.allow_override: if isinstance(local_lower_limits, dict):
raise MailProfileError(f"{protocol.upper()} credential override cannot be re-enabled below a locked ancestor policy") 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( def set_mail_profile_policy(
@@ -1078,7 +1262,7 @@ def set_mail_profile_policy(
group.mail_profile_policy = normalized group.mail_profile_policy = normalized
session.add(group) session.add(group)
return normalized 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: if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found") raise MailProfileError("Campaign policy not found")
campaign.mail_profile_policy = normalized campaign.mail_profile_policy = normalized
@@ -1086,6 +1270,23 @@ def set_mail_profile_policy(
return normalized 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( def materialize_campaign_mail_profile_config(
session: Session, session: Session,
*, *,
@@ -1133,13 +1334,13 @@ def materialize_campaign_mail_profile_config(
smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json") smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json")
if not profile_credentials_inherited_for_server(policy, server, "smtp"): if not profile_credentials_inherited_for_server(policy, server, "smtp"):
smtp_payload = apply_campaign_credentials(smtp_payload, 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) imap = imap_config_from_profile(profile)
if imap is not None: if imap is not None:
imap_payload = imap.model_dump(mode="json") imap_payload = imap.model_dump(mode="json")
if not profile_credentials_inherited_for_server(policy, server, "imap"): if not profile_credentials_inherited_for_server(policy, server, "imap"):
imap_payload = apply_campaign_credentials(imap_payload, 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: else:
resolved_server.pop("imap", None) resolved_server.pop("imap", None)
data["server"] = resolved_server data["server"] = resolved_server

View File

@@ -65,7 +65,7 @@ def _mail_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="mail", id="mail",
name="Mail", name="Mail",
version="1.0.0", version="0.1.4",
dependencies=("access",), dependencies=("access",),
optional_dependencies=(), optional_dependencies=(),
permissions=PERMISSIONS, permissions=PERMISSIONS,
@@ -82,9 +82,11 @@ manifest = ModuleManifest(
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), 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),
},
) )
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest

View File

@@ -25,7 +25,7 @@ from govoplan_core.db.session import get_session
from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.mail_profiles import (
MailProfileError, MailProfileError,
create_mail_server_profile, create_mail_server_profile,
effective_mail_profile_policy, effective_mail_profile_policy_for_scope,
get_mail_profile_policy, get_mail_profile_policy,
get_mail_server_profile, get_mail_server_profile,
imap_config_from_profile, imap_config_from_profile,
@@ -36,6 +36,7 @@ from govoplan_mail.backend.mail_profiles import (
smtp_config_from_profile, smtp_config_from_profile,
update_mail_server_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.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.sending.smtp import test_smtp_login from govoplan_mail.backend.sending.smtp import test_smtp_login
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -172,7 +173,7 @@ def create_profile(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_profile_write_scope(principal, payload.scope_type) _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) _require_profile_credentials_scope(principal, payload.scope_type)
try: try:
profile = create_mail_server_profile( profile = create_mail_server_profile(
@@ -182,8 +183,8 @@ def create_profile(
name=payload.name, name=payload.name,
slug=payload.slug, slug=payload.slug,
description=payload.description, description=payload.description,
smtp=payload.smtp, smtp=payload.smtp_config(),
imap=payload.imap, imap=payload.imap_config(),
is_active=payload.is_active, is_active=payload.is_active,
scope_type=payload.scope_type, scope_type=payload.scope_type,
scope_id=payload.scope_id, scope_id=payload.scope_id,
@@ -219,8 +220,18 @@ def update_profile(
try: try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) 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") _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") _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( update_mail_server_profile(
session, session,
profile, profile,
@@ -230,8 +241,8 @@ def update_profile(
slug=payload.slug, slug=payload.slug,
description=payload.description if "description" in payload.model_fields_set else None, description=payload.description if "description" in payload.model_fields_set else None,
is_active=payload.is_active, is_active=payload.is_active,
smtp=payload.smtp, smtp=smtp_config,
imap=payload.imap, imap=imap_config,
clear_imap=payload.clear_imap, clear_imap=payload.clear_imap,
) )
session.commit() session.commit()
@@ -273,14 +284,13 @@ def read_mail_profile_policy(
_require_policy_read_scope(principal, scope_type) _require_policy_read_scope(principal, scope_type)
try: try:
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
effective = None effective_policy = effective_mail_profile_policy_for_scope(
effective_sources = [] session,
if campaign_id: tenant_id=principal.tenant_id,
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) scope_type=scope_type,
effective = effective_policy.as_dict() scope_id=scope_id,
effective_sources = effective_policy.source_policies campaign_id=campaign_id,
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 = effective_policy.as_dict()
effective_sources = effective_policy.source_policies 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_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
@@ -310,10 +320,12 @@ def write_mail_profile_policy(
policy=payload.policy.model_dump(mode="json"), policy=payload.policy.model_dump(mode="json"),
) )
session.commit() session.commit()
effective = None effective_policy = effective_mail_profile_policy_for_scope(
effective_sources = [] session,
if scope_type == "campaign" and scope_id: tenant_id=principal.tenant_id,
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id) scope_type=scope_type,
scope_id=scope_id,
)
effective = effective_policy.as_dict() effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies 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_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
@@ -379,7 +391,7 @@ def list_profile_imap_folders(
if imap is None: if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration") raise MailProfileError("Mail-server profile has no IMAP configuration")
result = list_imap_folders(imap_config=imap) 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) 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: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -397,7 +409,7 @@ def list_profile_mailbox_folders(
try: try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_folders(imap_config=imap) 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) 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: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -412,13 +424,14 @@ def list_profile_mailbox_messages(
profile_id: str, profile_id: str,
folder: str = Query(default="INBOX", min_length=1, max_length=255), folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int = Query(default=50, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_mailbox_read_scope(principal) _require_mailbox_read_scope(principal)
try: try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit) result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset)
return MailMailboxMessageListResponse( return MailMailboxMessageListResponse(
profile_id=profile_id, profile_id=profile_id,
folder=result.folder, folder=result.folder,
@@ -426,6 +439,8 @@ def list_profile_mailbox_messages(
port=result.port, port=result.port,
security=result.security, security=result.security,
total_count=result.total_count, total_count=result.total_count,
offset=result.offset,
limit=result.limit,
messages=[_mailbox_summary_response(message) for message in result.messages], messages=[_mailbox_summary_response(message) for message in result.messages],
) )
except MailProfileError as exc: except MailProfileError as exc:
@@ -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") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
try: try:
result = list_imap_folders(imap_config=payload) 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( return MailImapFolderListResponse(
ok=True, ok=True,
host=result.host, host=result.host,

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal 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): class MailSmtpTestRequest(SmtpConfig):
@@ -15,7 +15,50 @@ class MailSmtpTestRequest(SmtpConfig):
class MailImapTestRequest(ImapConfig): class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form.""" """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"] MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
@@ -25,7 +68,6 @@ class MailCredentialPolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
inherit: bool | None = None inherit: bool | None = None
allow_override: bool | None = None
class MailProfilePolicyPayload(BaseModel): class MailProfilePolicyPayload(BaseModel):
@@ -39,6 +81,7 @@ class MailProfilePolicyPayload(BaseModel):
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
whitelist: dict[str, list[str]] = Field(default_factory=dict) whitelist: dict[str, list[str]] = Field(default_factory=dict)
blacklist: 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): class MailProfilePolicyUpdateRequest(BaseModel):
@@ -52,6 +95,7 @@ class PolicySourceStepResponse(BaseModel):
scope_id: str | None = None scope_id: str | None = None
label: str label: str
applied_fields: list[str] = Field(default_factory=list) applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
class MailProfilePolicyResponse(BaseModel): class MailProfilePolicyResponse(BaseModel):
@@ -73,8 +117,29 @@ class MailServerProfileCreateRequest(BaseModel):
is_active: bool = True is_active: bool = True
scope_type: MailProfileScope = "tenant" scope_type: MailProfileScope = "tenant"
scope_id: str | None = None scope_id: str | None = None
smtp: SmtpConfig smtp: SmtpServerConfig
imap: ImapConfig | None = None 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): class MailServerProfileUpdateRequest(BaseModel):
@@ -84,10 +149,35 @@ class MailServerProfileUpdateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100) slug: str | None = Field(default=None, max_length=100)
description: str | None = None description: str | None = None
is_active: bool | None = None is_active: bool | None = None
smtp: SmtpConfig | None = None smtp: SmtpServerConfig | None = None
imap: ImapConfig | None = None imap: ImapServerConfig | None = None
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
clear_imap: bool = False 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): class MailServerProfileResponse(BaseModel):
id: str id: str
@@ -100,6 +190,7 @@ class MailServerProfileResponse(BaseModel):
is_active: bool is_active: bool
smtp: dict[str, Any] smtp: dict[str, Any]
imap: dict[str, Any] | None = None imap: dict[str, Any] | None = None
credentials: dict[str, Any] = Field(default_factory=dict)
smtp_password_configured: bool = False smtp_password_configured: bool = False
imap_password_configured: bool = False imap_password_configured: bool = False
created_at: datetime created_at: datetime
@@ -123,6 +214,8 @@ class MailConnectionTestResponse(BaseModel):
class MailImapFolderResponse(BaseModel): class MailImapFolderResponse(BaseModel):
name: str name: str
flags: list[str] = Field(default_factory=list) flags: list[str] = Field(default_factory=list)
message_count: int | None = None
unseen_count: int | None = None
class MailImapFolderListResponse(BaseModel): class MailImapFolderListResponse(BaseModel):
@@ -171,6 +264,8 @@ class MailMailboxMessageListResponse(BaseModel):
port: int | None = None port: int | None = None
security: str | None = None security: str | None = None
total_count: int = 0 total_count: int = 0
offset: int = 0
limit: int = 50
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)

View File

@@ -50,6 +50,8 @@ class ImapLoginTestResult:
class ImapMailboxInfo: class ImapMailboxInfo:
name: str name: str
flags: list[str] flags: list[str]
message_count: int | None = None
unseen_count: int | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -111,6 +113,8 @@ class ImapMailboxMessageListResult:
folder: str folder: str
messages: list[ImapMailboxMessageSummary] messages: list[ImapMailboxMessageSummary]
total_count: int total_count: int
offset: int
limit: int
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -133,8 +137,6 @@ class ImapAppendResult:
def _require_imap_config(config: ImapConfig) -> tuple[str, int]: def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
if not config.enabled:
raise ImapConfigurationError("IMAP is disabled")
if not config.host: if not config.host:
raise ImapConfigurationError("IMAP host is required") raise ImapConfigurationError("IMAP host is required")
if not config.port: if not config.port:
@@ -311,7 +313,12 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
host, port = _require_imap_config(imap_config) host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host): 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( return ImapFolderListResult(
host=host, host=host,
port=port, port=port,
@@ -334,7 +341,8 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
continue continue
name, flags = extracted name, flags = extracted
parsed.append((name, flags)) 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( return ImapFolderListResult(
host=host, host=host,
@@ -350,6 +358,31 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
pass 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: def _parse_message(raw: bytes) -> EmailMessage:
return BytesParser(policy=policy.default).parsebytes(raw) return BytesParser(policy=policy.default).parsebytes(raw)
@@ -393,6 +426,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
return attachments 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: def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
message = _parse_message(raw) message = _parse_message(raw)
attachments = _attachment_infos(message) 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]: def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
metadata_parts: list[str] = [] metadata_parts: list[str] = []
raw: bytes | None = None raw: bytes | None = None
@@ -445,24 +504,38 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str
else: else:
metadata_parts.append(_decode_item(item)) metadata_parts.append(_decode_item(item))
metadata = " ".join(part for part in metadata_parts if part) metadata = " ".join(part for part in metadata_parts if part)
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE) uid, flags, size_bytes = _parse_fetch_metadata(metadata)
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 []
if raw is None: if raw is None:
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True) raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
return ( return uid, flags, size_bytes, raw
uid_match.group(1) if uid_match else None,
flags,
int(size_match.group(1)) if size_match else None,
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) typ, data = client.select(folder, readonly=True)
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
selected_count = _decode_item(data[0] if data else None).strip()
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]: 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 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: def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip() folder = str(record.get("folder") or "").strip()
if folder: if folder:
@@ -495,14 +612,16 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace") return "\n".join(lines).encode("utf-8", errors="replace")
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> 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.""" """List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config) host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX" folder = (folder or "INBOX").strip() or "INBOX"
limit = max(1, min(limit, 100)) limit = max(1, min(limit, 100))
offset = max(0, offset)
if is_mock_imap_host(imap_config.host): if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
page_records = records[offset:offset + limit]
messages = [ messages = [
_message_summary_from_raw( _message_summary_from_raw(
uid=str(record.get("id") or ""), 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 [], flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None, 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) client = _open_imap(imap_config)
try: try:
_select_readonly(client, folder) total_count = _select_readonly(client, folder)
typ, data = client.uid("search", None, "ALL") page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
if typ != "OK": messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True) return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit)
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))
finally: finally:
try: try:
client.logout() client.logout()
@@ -535,6 +647,14 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
pass 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: 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.""" """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

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.0", "version": "0.1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,10 +14,21 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css" "./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.0", "@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@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 & { export type MailImapTestPayload = MailSmtpTestPayload & {
enabled?: boolean;
sent_folder?: string | null; sent_folder?: string | null;
}; };
export type MailTransportCredentialsPayload = {
username?: string | null;
password?: string | null;
};
export type MailServerProfileCredentialsPayload = {
smtp?: MailTransportCredentialsPayload;
imap?: MailTransportCredentialsPayload;
};
export type MailConnectionTestResponse = { export type MailConnectionTestResponse = {
ok: boolean; ok: boolean;
protocol: "smtp" | "imap"; protocol: "smtp" | "imap";
@@ -31,6 +40,8 @@ export type MailConnectionTestResponse = {
export type MailImapFolderResponse = { export type MailImapFolderResponse = {
name: string; name: string;
flags?: string[]; flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
}; };
export type MailImapFolderListResponse = { export type MailImapFolderListResponse = {
@@ -59,6 +70,7 @@ export type MailMailboxMessageSummary = {
from_header?: string | null; from_header?: string | null;
to_header?: string | null; to_header?: string | null;
cc_header?: string | null; cc_header?: string | null;
bcc_header?: string | null;
date?: string | null; date?: string | null;
message_id?: string | null; message_id?: string | null;
flags?: string[]; flags?: string[];
@@ -81,6 +93,8 @@ export type MailMailboxMessageListResponse = {
port?: number | null; port?: number | null;
security?: MailSecurity | string | null; security?: MailSecurity | string | null;
total_count?: number; total_count?: number;
offset?: number;
limit?: number;
messages: MailMailboxMessageSummary[]; messages: MailMailboxMessageSummary[];
}; };
@@ -104,6 +118,7 @@ export type MailServerProfile = {
is_active: boolean; is_active: boolean;
smtp: MailSmtpTestPayload; smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null; imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
smtp_password_configured: boolean; smtp_password_configured: boolean;
imap_password_configured: boolean; imap_password_configured: boolean;
created_at: string; created_at: string;
@@ -123,9 +138,30 @@ export const mailProfilePatternKeys = [
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number]; export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>; 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 = { export type MailCredentialPolicy = {
inherit?: boolean | null; inherit?: boolean | null;
allow_override?: boolean | null;
}; };
export type MailProfilePolicy = { export type MailProfilePolicy = {
@@ -137,6 +173,7 @@ export type MailProfilePolicy = {
imap_credentials?: MailCredentialPolicy | null; imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null; whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null; blacklist?: MailProfilePatternRules | null;
allow_lower_level_limits?: MailProfilePolicyLimitPermissions | null;
}; };
export type PolicySourceStep = { export type PolicySourceStep = {
@@ -144,6 +181,7 @@ export type PolicySourceStep = {
scope_id?: string | null; scope_id?: string | null;
label: string; label: string;
applied_fields?: string[]; applied_fields?: string[];
policy?: MailProfilePolicy | null;
}; };
export type MailProfilePolicyResponse = { export type MailProfilePolicyResponse = {
@@ -165,6 +203,7 @@ export type MailServerProfilePayload = {
scope_id?: string | null; scope_id?: string | null;
smtp: MailSmtpTestPayload; smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null; imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
}; };
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> { export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
@@ -244,9 +283,10 @@ export async function listMailboxMessages(
settings: ApiSettings, settings: ApiSettings,
profileId: string, profileId: string,
folder = "INBOX", folder = "INBOX",
limit = 50 limit = 50,
offset = 0
): Promise<MailMailboxMessageListResponse> { ): 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()}`); 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 { 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 { import {
Button, Button,
DismissibleAlert, DismissibleAlert,
@@ -19,14 +19,7 @@ import {
type MailMailboxMessageSummary, type MailMailboxMessageSummary,
type MailServerProfile type MailServerProfile
} from "../../api/mail"; } from "../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
type MailFolderNode = {
id: string;
label: string;
folderName: string | null;
flags: string[];
children: MailFolderNode[];
};
export default function MailboxPage({ settings }: { settings: ApiSettings }) { export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]); 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 [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]); const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null); const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [messagePage, setMessagePage] = useState(1);
const [messagePageSize, setMessagePageSize] = useState(10);
const [messageQuery, setMessageQuery] = useState("");
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null); const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
const [selectedMessageKeyState, setSelectedMessageKeyState] = useState("");
const [pendingMessageKey, setPendingMessageKey] = useState(""); const [pendingMessageKey, setPendingMessageKey] = useState("");
const [loadingProfiles, setLoadingProfiles] = useState(true); const [loadingProfiles, setLoadingProfiles] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(false); const [loadingFolders, setLoadingFolders] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(false); const [loadingMessage, setLoadingMessage] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [folderError, setFolderError] = useState("");
const [messageError, setMessageError] = useState("");
const [detailError, setDetailError] = useState("");
const selectedMessageKeyRef = useRef("");
const folderRequestRef = useRef(0); const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0); const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0); const messageDetailRequestRef = useRef(0);
@@ -52,19 +53,54 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]); const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]); const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]); 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 shellBusy = loadingProfiles || loadingFolders || loadingMessages;
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0; const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId; 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 messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available."; const folderEmptyText = folderError || (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 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 = loadingMessage ? "Loading message..." : "Select a message to inspect its content."; const previewEmptyText = detailError || (loadingMessage ? "Loading message..." : "Select a message to inspect its content.");
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message..."; const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); 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) 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 handlePreviewShortcut = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (event.key === "Escape") {
if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) {
event.preventDefault();
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setDetailError("");
}
return;
}
if (shellBusy || loadingMessage || filteredMessages.length === 0) return;
const selectedIndex = filteredMessages.findIndex((message) => mailboxMessageKey(message.folder || selectedFolder, message.uid) === selectedMessageKey);
let nextIndex: number | null = null;
if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;
else if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = filteredMessages.length - 1;
if (nextIndex === null) return;
event.preventDefault();
const nextMessage = filteredMessages[nextIndex];
if (nextMessage) void openMessage(nextMessage);
};
window.addEventListener("keydown", handlePreviewShortcut);
return () => window.removeEventListener("keydown", handlePreviewShortcut);
}, [detailError, filteredMessages, loadingMessage, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
useEffect(() => { useEffect(() => {
const ancestorIds = folderAncestorIds(folderTree, selectedFolder); const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
@@ -90,6 +126,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
} }
} catch (err) { } catch (err) {
@@ -107,7 +144,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setLoadingFolders(true); setLoadingFolders(true);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessageTotalCount(null); setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
setFolderError("");
setMessageError("");
setDetailError("");
setError(""); setError("");
try { try {
const response = await listMailboxFolders(settings, profileId); const response = await listMailboxFolders(settings, profileId);
@@ -124,40 +167,51 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setFoldersLoadedForProfile(profileId); setFoldersLoadedForProfile(profileId);
} catch (err) { } catch (err) {
if (requestId !== folderRequestRef.current) return; if (requestId !== folderRequestRef.current) return;
setFolderError(errorText(err));
setError(errorText(err)); setError(errorText(err));
setFolders([]); setFolders([]);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === folderRequestRef.current) setLoadingFolders(false); 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; if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current; const requestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1; const offset = (Math.max(1, page) - 1) * pageSize;
setLoadingMessages(true); setLoadingMessages(true);
setMessageTotalCount(null); setMessageError("");
setSelectedMessage(null); setDetailError("");
setPendingMessageKey("");
setError(""); setError("");
try { try {
const response = await listMailboxMessages(settings, profileId, folder, 50); const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset);
if (requestId !== messageListRequestRef.current) return; if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? []; const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length;
setMessages(loaded); setMessages(loaded);
setMessageTotalCount(response.total_count ?? loaded.length); setMessageTotalCount(total);
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
}
} catch (err) { } catch (err) {
if (requestId !== messageListRequestRef.current) return; if (requestId !== messageListRequestRef.current) return;
setError(errorText(err)); const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false); if (requestId === messageListRequestRef.current) setLoadingMessages(false);
@@ -168,18 +222,25 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (!selectedProfileId) return; if (!selectedProfileId) return;
const folderName = message.folder || selectedFolder; const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current; const requestId = ++messageDetailRequestRef.current;
setPendingMessageKey(mailboxMessageKey(folderName, message.uid)); const nextKey = mailboxMessageKey(folderName, message.uid);
setSelectedMessageKeyState(nextKey);
selectedMessageKeyRef.current = nextKey;
setPendingMessageKey(nextKey);
setSelectedMessage(null); setSelectedMessage(null);
setDetailError("");
setLoadingMessage(true); setLoadingMessage(true);
setError(""); setError("");
try { try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid); const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
if (requestId !== messageDetailRequestRef.current) return; if (requestId !== messageDetailRequestRef.current) return;
setSelectedMessage(response.message); setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
setPendingMessageKey(""); setPendingMessageKey("");
} catch (err) { } catch (err) {
if (requestId !== messageDetailRequestRef.current) return; if (requestId !== messageDetailRequestRef.current) return;
setError(errorText(err)); const messageText = errorText(err);
setDetailError(messageText);
setError(messageText);
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false); if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
@@ -195,7 +256,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey(""); setPendingMessageKey("");
setExpandedFolders(new Set()); setExpandedFolders(new Set());
setLoadingFolders(false); setLoadingFolders(false);
@@ -205,8 +269,16 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function openFolderNode(node: MailFolderNode) { function openFolderNode(node: MailFolderNode) {
if (node.folderName) { if (node.folderName) {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName); if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);
else setSelectedFolder(node.folderName); else {
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setDetailError("");
}
return; return;
} }
toggleFolderNode(node); toggleFolderNode(node);
@@ -221,6 +293,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 ( return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page"> <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>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
@@ -242,12 +328,12 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
disabled={shellBusy} disabled={shellBusy}
renderNodeContent={(node) => { renderNodeContent={(node) => {
const flagText = folderFlagLabel(node.flags); const flagText = folderFlagLabel(node.flags);
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null; const showCount = foldersReady && node.messageCount !== null;
return ( return (
<span className="mailbox-tree-node-label"> <span className="mailbox-tree-node-label">
<span className="mailbox-tree-node-main"> <span className="mailbox-tree-node-main">
<span>{node.label}</span> <span>{node.label}</span>
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>} {showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
</span> </span>
{flagText && <small>{flagText}</small>} {flagText && <small>{flagText}</small>}
</span> </span>
@@ -262,22 +348,22 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions"> <div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
<label className="mailbox-profile-field"> <label className="mailbox-profile-field">
<span>IMAP profile</span> <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.length === 0 && <option value="">No IMAP profiles available</option>}
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)} {imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select> </select>
</label> </label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span> <span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
<div className="mailbox-toolbar-actions"> <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" /> <RefreshCw size={16} aria-hidden="true" />
Profiles Profiles
</Button> </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" /> <RefreshCw size={16} aria-hidden="true" />
Folders Folders
</Button> </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" /> <RefreshCw size={16} aria-hidden="true" />
Messages Messages
</Button> </Button>
@@ -289,9 +375,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> <span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
</nav> </nav>
<div className="mailbox-filter-row">
<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"> <div className="file-list-meta">
<span>{messageCountLabel}</span> <span>{messageCountLabel}</span>
<span>{selectedFolder}</span> <span>{selectedFolder}</span>
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} on page</span>}
{shellBusy && <span>Working...</span>} {shellBusy && <span>Working...</span>}
{loadingMessage && <span>Loading preview...</span>} {loadingMessage && <span>Loading preview...</span>}
</div> </div>
@@ -305,8 +400,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-list-drop-target mailbox-message-scroll"> <div className="file-list-drop-target mailbox-message-scroll">
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages"> <div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>} {filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>}
{messages.map((message) => { {filteredMessages.map((message) => {
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid); const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
const selected = key === selectedMessageKey; const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey; const loadingSelected = loadingMessage && key === pendingMessageKey;
@@ -343,6 +438,14 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
})} })}
</div> </div>
</div> </div>
<MailboxPagination
page={messagePage}
pageSize={messagePageSize}
totalRows={messageTotalCount ?? 0}
disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize}
/>
</section> </section>
<section className="mailbox-preview-panel" aria-label="Message preview"> <section className="mailbox-preview-panel" aria-label="Message preview">
@@ -354,6 +457,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{ label: "From", value: selectedMessage.from_header || "-" }, { label: "From", value: selectedMessage.from_header || "-" },
{ label: "To", value: selectedMessage.to_header || "-" }, { label: "To", value: selectedMessage.to_header || "-" },
{ label: "Cc", value: selectedMessage.cc_header || null }, { label: "Cc", value: selectedMessage.cc_header || null },
{ label: "Bcc", value: selectedMessage.bcc_header || null },
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) } { label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
] : []} ] : []}
bodyText={selectedMessage?.body_text} bodyText={selectedMessage?.body_text}
@@ -380,81 +484,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 { function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: { page: number; pageSize: number; totalRows: number; disabled: boolean; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void }) {
if (index < 0 || index >= parts.length) return null; const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
const id = parts.slice(0, index + 1).join("/"); const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const existing = byId.get(id); const last = Math.min(totalRows, page * pageSize);
if (existing) return existing; const options = [10, 25, 50, 100];
return (
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], children: [] }; <div className="data-grid-pagination mailbox-pagination" aria-label="Mailbox message pagination">
byId.set(id, node); <div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div>
<label className="data-grid-page-size">
if (index === 0) { <span>Rows per page</span>
roots.push(node); <select value={pageSize} disabled={disabled} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
} else { {options.map((value) => <option key={value} value={value}>{value}</option>)}
ensureNode(parts, index - 1)?.children.push(node); </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>
);
} }
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 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 { function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`; 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 { function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
if (!ready) return "No folder loaded"; if (!ready) return "No folder loaded";
if (loading) return "Loading messages"; if (loading) return "Loading messages";
@@ -508,3 +580,8 @@ function formatBytes(value?: number | null): string {
function errorText(error: unknown): string { function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
}

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 { 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 MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"]; const mailboxRead = ["mail:mailbox:read"];
@@ -12,7 +14,13 @@ export const mailModule: PlatformWebModule = {
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }], navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [ routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) } { path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
] ],
uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
},
runtimeUiCapabilities: {
"mail.devMailbox": { enabled: true, label: "Development mock mailbox" } satisfies MailDevMailboxUiCapability
}
}; };
export default mailModule; export default mailModule;

View File

@@ -123,6 +123,42 @@
overflow-wrap: anywhere; 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 { .mail-policy-pattern-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -179,10 +215,19 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-form-grid.three-columns, .admin-form-grid.three-columns,
.mail-policy-pattern-grid, .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; grid-template-columns: 1fr;
} }
.mail-policy-row-header {
display: none;
}
.mail-credential-inheritance-row { .mail-credential-inheritance-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -539,6 +584,62 @@
min-width: 660px; 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-head,
.mailbox-message-row { .mailbox-message-row {
grid-template-columns: minmax(260px, 1fr) 190px 130px; 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"
]
}