diff --git a/.gitignore b/.gitignore index 3c46444..cb85520 100644 --- a/.gitignore +++ b/.gitignore @@ -326,3 +326,6 @@ cython_debug/ # Built Visual Studio Code Extensions *.vsix + +# GovOPlaN WebUI test output +webui/.mail-test-build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e8f3d20 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index e120994..f239d86 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ This repository owns: Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout. +## Credential inheritance policy + +SMTP and IMAP each have one credential inheritance decision: descendants inherit profile credentials, may inherit profile credentials, or must provide local credentials. The lower-level override switch for `smtp_credentials.inherit` and `imap_credentials.inherit` decides whether child scopes may change that decision. There is no separate "override the override" credential policy field. + ## Development Install through the core environment: diff --git a/pyproject.toml b/pyproject.toml index 5bb1334..725bf12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-mail" -version = "0.1.1" +version = "0.1.2" description = "GovOPlaN mail module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.1", + "govoplan-core>=0.1.2", "pydantic>=2,<3", "redis>=5,<6", "SQLAlchemy>=2,<3", diff --git a/src/govoplan_mail/backend/capabilities.py b/src/govoplan_mail/backend/capabilities.py new file mode 100644 index 0000000..7c701d7 --- /dev/null +++ b/src/govoplan_mail/backend/capabilities.py @@ -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() diff --git a/src/govoplan_mail/backend/config.py b/src/govoplan_mail/backend/config.py index 194e35c..204f4ac 100644 --- a/src/govoplan_mail/backend/config.py +++ b/src/govoplan_mail/backend/config.py @@ -1,35 +1,21 @@ from __future__ import annotations -from enum import StrEnum +from govoplan_core.mail.config import ( + ImapConfig, + ImapServerConfig, + SmtpConfig, + SmtpServerConfig, + StrictModel, + TransportCredentials, + TransportSecurity, +) -from pydantic import BaseModel, ConfigDict, Field - - -class StrictModel(BaseModel): - model_config = ConfigDict(extra="forbid", populate_by_name=True) - - -class TransportSecurity(StrEnum): - PLAIN = "plain" - TLS = "tls" - STARTTLS = "starttls" - - -class SmtpConfig(StrictModel): - host: str | None = None - port: int | None = Field(default=None, ge=1, le=65535) - username: str | None = None - password: str | None = None - security: TransportSecurity = TransportSecurity.STARTTLS - timeout_seconds: int = Field(default=30, ge=1) - - -class ImapConfig(StrictModel): - enabled: bool = False - host: str | None = None - port: int | None = Field(default=None, ge=1, le=65535) - username: str | None = None - password: str | None = None - security: TransportSecurity = TransportSecurity.TLS - sent_folder: str = "auto" - timeout_seconds: int = Field(default=30, ge=1) +__all__ = [ + "ImapConfig", + "ImapServerConfig", + "SmtpConfig", + "SmtpServerConfig", + "StrictModel", + "TransportCredentials", + "TransportSecurity", +] diff --git a/src/govoplan_mail/backend/db/models.py b/src/govoplan_mail/backend/db/models.py index 17aa0ac..b553eb9 100644 --- a/src/govoplan_mail/backend/db/models.py +++ b/src/govoplan_mail/backend/db/models.py @@ -29,8 +29,10 @@ class MailServerProfile(Base, TimestampMixin): description: Mapped[str | None] = mapped_column(Text) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + smtp_username: Mapped[str | None] = mapped_column(String(320)) smtp_password_encrypted: Mapped[str | None] = mapped_column(Text) imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + imap_username: Mapped[str | None] = mapped_column(String(320)) imap_password_encrypted: Mapped[str | None] = mapped_column(Text) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index 0429c67..2f85c38 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -10,8 +10,8 @@ from sqlalchemy import and_, or_ from sqlalchemy.orm import Session from govoplan_core.admin.governance import get_system_settings +from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership -from govoplan_campaign.backend.db.models import Campaign from govoplan_mail.backend.db.models import MailServerProfile from govoplan_mail.backend.config import ImapConfig, SmtpConfig from govoplan_core.security.secrets import decrypt_secret, encrypt_secret @@ -25,20 +25,47 @@ MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy" PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"} PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "envelope_senders", "from_headers", "recipient_domains") PROFILE_SCOPE_ORDER = {"system": 0, "tenant": 1, "user": 2, "group": 2, "campaign": 3} +MAIL_POLICY_LIMIT_KEYS = ( + "allowed_profile_ids", + "allow_user_profiles", + "allow_group_profiles", + "allow_campaign_profiles", + "smtp_credentials.inherit", + "imap_credentials.inherit", + "whitelist.smtp_hosts", + "whitelist.imap_hosts", + "whitelist.envelope_senders", + "whitelist.from_headers", + "whitelist.recipient_domains", + "blacklist.smtp_hosts", + "blacklist.imap_hosts", + "blacklist.envelope_senders", + "blacklist.from_headers", + "blacklist.recipient_domains", +) + + +def default_mail_policy_lower_level_limits() -> dict[str, bool]: + return {key: True for key in MAIL_POLICY_LIMIT_KEYS} + + +def _campaign_model(): + try: + from govoplan_campaign.backend.db.models import Campaign + except ModuleNotFoundError as exc: + reraise_unless_missing_package(exc, "govoplan_campaign") + raise MailProfileError("Campaign module is not installed") from exc + return Campaign @dataclass(slots=True) class EffectiveCredentialPolicy: inherit: bool = True - allow_override: bool = True explicit_inherit: bool = False inherit_source: str | None = None def as_dict(self) -> dict[str, Any]: - return { - "inherit": self.inherit, - "allow_override": self.allow_override, - } + return {"inherit": self.inherit} @dataclass(slots=True) @@ -51,6 +78,7 @@ class EffectiveMailProfilePolicy: imap_credentials: EffectiveCredentialPolicy = field(default_factory=EffectiveCredentialPolicy) whitelist_groups: dict[str, list[list[str]]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS}) blacklist_patterns: dict[str, list[str]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS}) + allow_lower_level_limits: dict[str, bool] = field(default_factory=default_mail_policy_lower_level_limits) source_policies: list[dict[str, Any]] = field(default_factory=list) def profile_id_allowed(self, profile_id: str) -> bool: @@ -87,6 +115,7 @@ class EffectiveMailProfilePolicy: "imap_credentials": self.imap_credentials.as_dict(), "whitelist": whitelist, "blacklist": blacklist, + "allow_lower_level_limits": dict(self.allow_lower_level_limits), } @@ -95,11 +124,14 @@ def slugify_profile_name(value: str) -> str: return slug or "mail-profile" -def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, bool]: +def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]: payload = config.model_dump(mode="json") + username_was_supplied = "username" in config.model_fields_set password_was_supplied = "password" in config.model_fields_set + username = payload.pop("username", None) password = payload.pop("password", None) - return payload, password, password_was_supplied + payload.pop("enabled", None) + return payload, username, password, username_was_supplied, password_was_supplied def _normalize_scope_type(scope_type: str | None) -> str: @@ -162,11 +194,20 @@ def _normalize_credential_policy(value: Any) -> dict[str, bool | None]: inherit = data.get("inherit") if inherit is None: inherit = data.get("inherit_credentials") - allow_override = data.get("allow_override") - return { - "inherit": inherit if isinstance(inherit, bool) else None, - "allow_override": allow_override if isinstance(allow_override, bool) else None, - } + return {"inherit": inherit if isinstance(inherit, bool) else None} + + +def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool = False) -> dict[str, bool]: + if value is None: + return default_mail_policy_lower_level_limits() if fill_defaults else {} + if not isinstance(value, dict): + raise MailProfileError("Mail profile lower-level limit policy must be an object") + normalized = default_mail_policy_lower_level_limits() if fill_defaults else {} + for key in MAIL_POLICY_LIMIT_KEYS: + raw_value = value.get(key) + if isinstance(raw_value, bool): + normalized[key] = raw_value + return normalized def normalize_mail_profile_policy(payload: dict[str, Any] | None) -> dict[str, Any]: @@ -180,6 +221,7 @@ def normalize_mail_profile_policy(payload: dict[str, Any] | None) -> dict[str, A "imap_credentials": _normalize_credential_policy(data.get("imap_credentials")), "whitelist": whitelist, "blacklist": blacklist, + "allow_lower_level_limits": _normalize_allow_lower_level_limits(data.get("allow_lower_level_limits")), } for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"): value = data.get(key) @@ -211,68 +253,117 @@ def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False if isinstance(credential, dict): if isinstance(credential.get("inherit"), bool): fields.append(f"{key}.inherit") - if isinstance(credential.get("allow_override"), bool): - fields.append(f"{key}.allow_override") for kind in ("whitelist", "blacklist"): rules = policy.get(kind) or {} if isinstance(rules, dict): for key in PROFILE_PATTERN_KEYS: if _clean_string_list(rules.get(key, [])): fields.append(f"{kind}.{key}") + allow_lower = policy.get("allow_lower_level_limits") or {} + if isinstance(allow_lower, dict): + for key in MAIL_POLICY_LIMIT_KEYS: + if isinstance(allow_lower.get(key), bool): + fields.append(f"allow_lower_level_limits.{key}") if baseline and not fields: fields.append("defaults") return fields +def _mail_policy_source_policy(policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]: + normalized = normalize_mail_profile_policy(policy) + if not baseline: + return normalized + source_policy = EffectiveMailProfilePolicy().as_dict() + allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) + if allowed_ids: + source_policy["allowed_profile_ids"] = allowed_ids + for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"): + value = normalized.get(key) + if isinstance(value, bool): + source_policy[key] = value + for key in ("smtp_credentials", "imap_credentials"): + credential = normalized.get(key) or {} + source_credential = dict(source_policy.get(key) or {}) + if isinstance(credential, dict): + if isinstance(credential.get("inherit"), bool): + source_credential["inherit"] = credential["inherit"] + source_policy[key] = source_credential + source_policy["whitelist"] = dict(normalized.get("whitelist") or {}) + source_policy["blacklist"] = dict(normalized.get("blacklist") or {}) + source_policy["allow_lower_level_limits"] = { + **default_mail_policy_lower_level_limits(), + **(normalized.get("allow_lower_level_limits") or {}), + } + return source_policy + + def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]: return { "scope_type": source, "scope_id": source_id, "label": label, "applied_fields": _mail_policy_source_fields(policy, baseline=baseline), + "policy": _mail_policy_source_policy(policy, baseline=baseline), } -def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None: - inherit = data.get("inherit") - if isinstance(inherit, bool): - if current.allow_override: - current.inherit = inherit - current.explicit_inherit = True - current.inherit_source = source - elif inherit == current.inherit: - current.explicit_inherit = True - allow_override = data.get("allow_override") - if isinstance(allow_override, bool): - if current.allow_override: - current.allow_override = allow_override - elif allow_override is False: - current.allow_override = False +def _merge_credential_policy( + current: EffectiveCredentialPolicy, + data: dict[str, Any], + *, + source: str, + lower_limits: dict[str, bool], + inherit_limit_key: str, +) -> None: + inherit = data.get("inherit") + if isinstance(inherit, bool) and lower_limits.get(inherit_limit_key, True): + current.inherit = inherit + current.explicit_inherit = True + current.inherit_source = source def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str, source_id: str | None = None, label: str | None = None, baseline: bool = False) -> None: normalized = normalize_mail_profile_policy(data) policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline)) + lower_limits = dict(policy.allow_lower_level_limits) allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) - if allowed_ids: + if allowed_ids and lower_limits.get("allowed_profile_ids", True): policy.allowed_profile_id_sets.append(set(allowed_ids)) - if normalized.get("allow_user_profiles") is False: + if normalized.get("allow_user_profiles") is False and lower_limits.get("allow_user_profiles", True): policy.allow_user_profiles = False - if normalized.get("allow_group_profiles") is False: + if normalized.get("allow_group_profiles") is False and lower_limits.get("allow_group_profiles", True): policy.allow_group_profiles = False - if normalized.get("allow_campaign_profiles") is False: + if normalized.get("allow_campaign_profiles") is False and lower_limits.get("allow_campaign_profiles", True): policy.allow_campaign_profiles = False - _merge_credential_policy(policy.smtp_credentials, normalized.get("smtp_credentials") or {}, source=source) - _merge_credential_policy(policy.imap_credentials, normalized.get("imap_credentials") or {}, source=source) + _merge_credential_policy( + policy.smtp_credentials, + normalized.get("smtp_credentials") or {}, + source=source, + lower_limits=lower_limits, + inherit_limit_key="smtp_credentials.inherit", + ) + _merge_credential_policy( + policy.imap_credentials, + normalized.get("imap_credentials") or {}, + source=source, + lower_limits=lower_limits, + inherit_limit_key="imap_credentials.inherit", + ) whitelist = normalized.get("whitelist") or {} blacklist = normalized.get("blacklist") or {} for key in PROFILE_PATTERN_KEYS: allow_patterns = _meaningful_allow_patterns(whitelist.get(key, [])) - if allow_patterns: + if allow_patterns and lower_limits.get(f"whitelist.{key}", True): policy.whitelist_groups.setdefault(key, []).append(allow_patterns) deny_patterns = _clean_string_list(blacklist.get(key, [])) - if deny_patterns: + if deny_patterns and lower_limits.get(f"blacklist.{key}", True): policy.blacklist_patterns.setdefault(key, []).extend(deny_patterns) + local_lower_limits = normalized.get("allow_lower_level_limits") or {} + if local_lower_limits: + policy.allow_lower_level_limits = { + key: lower_limits.get(key, True) and local_lower_limits.get(key, lower_limits.get(key, True)) + for key in MAIL_POLICY_LIMIT_KEYS + } def _owner_context( @@ -282,10 +373,10 @@ def _owner_context( campaign_id: str | None = None, owner_user_id: str | None = None, owner_group_id: str | None = None, -) -> tuple[Campaign | None, str | None, str | None]: +) -> tuple[Any | None, str | None, str | None]: campaign = None if campaign_id: - campaign = session.get(Campaign, campaign_id) + campaign = session.get(_campaign_model(), campaign_id) if campaign is None or campaign.tenant_id != tenant_id: raise MailProfileError("Campaign not found for mail-server profile policy") owner_user_id = campaign.owner_user_id @@ -329,6 +420,38 @@ def effective_mail_profile_policy( return policy + +def effective_mail_profile_policy_for_scope( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None = None, + campaign_id: str | None = None, +) -> EffectiveMailProfilePolicy: + clean_scope = _normalize_scope_type(scope_type) + if campaign_id: + return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) + if clean_scope == "system": + policy = EffectiveMailProfilePolicy() + system_settings = get_system_settings(session) + _merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True) + return policy + if clean_scope == "tenant": + return effective_mail_profile_policy(session, tenant_id=tenant_id) + if clean_scope == "user": + if not scope_id: + raise MailProfileError("User mail profile policy requires scope_id") + return effective_mail_profile_policy(session, tenant_id=tenant_id, owner_user_id=scope_id) + if clean_scope == "group": + if not scope_id: + raise MailProfileError("Group mail profile policy requires scope_id") + return effective_mail_profile_policy(session, tenant_id=tenant_id, owner_group_id=scope_id) + if not scope_id: + raise MailProfileError("Campaign mail profile policy requires scope_id") + return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id) + + def parent_mail_profile_policy( session: Session, *, @@ -353,7 +476,7 @@ def parent_mail_profile_policy( if clean_scope != "campaign" or not scope_id: return policy - campaign = session.get(Campaign, scope_id) + campaign = session.get(_campaign_model(), scope_id) if campaign is None or campaign.tenant_id != tenant_id: raise MailProfileError("Campaign policy not found") if campaign.owner_user_id: @@ -470,7 +593,7 @@ def _scope_tuple_for_create( return tenant_id, clean_scope, clean_scope_id if not clean_scope_id: raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id") - campaign = session.get(Campaign, clean_scope_id) + campaign = session.get(_campaign_model(), clean_scope_id) if campaign is None or campaign.tenant_id != tenant_id: raise MailProfileError("Campaign scope is not part of the active tenant") return tenant_id, clean_scope, clean_scope_id @@ -524,7 +647,7 @@ def _profile_visible_filter(tenant_id: str): return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id) -def _context_profile_clauses(campaign: Campaign, policy: EffectiveMailProfilePolicy) -> list[Any]: +def _context_profile_clauses(campaign: Any, policy: EffectiveMailProfilePolicy) -> list[Any]: clauses: list[Any] = [ MailServerProfile.scope_type == "system", and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"), @@ -551,7 +674,7 @@ def _profile_allowed_for_context( *, tenant_id: str, policy: EffectiveMailProfilePolicy, - campaign: Campaign | None = None, + campaign: Any | None = None, owner_user_id: str | None = None, owner_group_id: str | None = None, ) -> bool: @@ -588,7 +711,7 @@ def list_mail_server_profiles( campaign_id: str | None = None, ) -> list[MailServerProfile]: if campaign_id: - campaign = session.get(Campaign, campaign_id) + campaign = session.get(_campaign_model(), campaign_id) if campaign is None or campaign.tenant_id != tenant_id: raise MailProfileError("Campaign not found for mail-server profiles") policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id) @@ -639,7 +762,7 @@ def ensure_mail_profile_allowed_for_campaign( profile_id: str, require_active: bool = True, ) -> MailServerProfile: - campaign = session.get(Campaign, campaign_id) + campaign = session.get(_campaign_model(), campaign_id) if campaign is None or campaign.tenant_id != tenant_id: raise MailProfileError("Campaign not found for mail-server profile policy") profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active) @@ -668,10 +791,18 @@ def _legacy_profile_credentials_inherited(server: dict[str, Any], protocol: str) return server.get(f"inherit_{protocol}_credentials") is not False +def _local_credential_inheritance_override_allowed(policy: EffectiveMailProfilePolicy, protocol: str) -> bool: + credential_policy = _credential_policy_for_protocol(policy, protocol) + return ( + credential_policy.inherit_source != "campaign" + and policy.allow_lower_level_limits.get(f"{protocol}_credentials.inherit", True) + ) + + def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool: credential_policy = _credential_policy_for_protocol(policy, protocol) legacy_key = f"inherit_{protocol}_credentials" - if legacy_key in server and credential_policy.allow_override and credential_policy.inherit_source != "campaign": + if legacy_key in server and _local_credential_inheritance_override_allowed(policy, protocol): return _legacy_profile_credentials_inherited(server, protocol) return credential_policy.inherit @@ -718,7 +849,7 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p legacy_key = f"inherit_{protocol}_credentials" if legacy_key in server: legacy_inherit = _legacy_profile_credentials_inherited(server, protocol) - if (not credential_policy.allow_override or credential_policy.inherit_source == "campaign") and legacy_inherit != credential_policy.inherit: + if not _local_credential_inheritance_override_allowed(policy, protocol) and legacy_inherit != credential_policy.inherit: raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy") inherited = profile_credentials_inherited_for_server(policy, server, protocol) username, password = _transport_credentials_from_server(server, protocol) @@ -728,14 +859,16 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy") -def _inline_transport_configured(value: Any) -> bool: - if not isinstance(value, dict): - return False - if value.get("enabled") is True: - return True - for key in ("host", "username", "password"): +def _inline_transport_configured(server: dict[str, Any], protocol: str) -> bool: + value = server.get(protocol) if isinstance(server.get(protocol), dict) else {} + credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {} + credential_value = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {} + for key in ("host",): if str(value.get(key) or "").strip(): return True + for key in ("username", "password"): + if str(credential_value.get(key) or value.get(key) or "").strip(): + return True return False @@ -784,7 +917,7 @@ def assert_campaign_mail_policy_allows_json( imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None if campaign_id: policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) - if not policy.allow_campaign_profiles and (_inline_transport_configured(smtp) or _inline_transport_configured(imap)): + if not policy.allow_campaign_profiles and (_inline_transport_configured(server, "smtp") or _inline_transport_configured(server, "imap")): raise MailProfileError("Campaign-local inline mail settings are disabled by policy") _assert_transport_values_allowed(policy, smtp, imap) return @@ -834,11 +967,12 @@ def create_mail_server_profile( assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap) clean_slug = slugify_profile_name(slug or clean_name) _ensure_unique_slug(session, scope_type=clean_scope_type, scope_id=clean_scope_id, slug=clean_slug) - smtp_payload, smtp_password, _ = _transport_payload(smtp) + smtp_payload, smtp_username, smtp_password, _, _ = _transport_payload(smtp) imap_payload = None + imap_username = None imap_password = None if imap is not None: - imap_payload, imap_password, _ = _transport_payload(imap) + imap_payload, imap_username, imap_password, _, _ = _transport_payload(imap) profile = MailServerProfile( tenant_id=profile_tenant_id, scope_type=clean_scope_type, @@ -848,8 +982,10 @@ def create_mail_server_profile( description=description, is_active=is_active, smtp_config=smtp_payload, + smtp_username=smtp_username, smtp_password_encrypted=encrypt_secret(smtp_password), imap_config=imap_payload, + imap_username=imap_username, imap_password_encrypted=encrypt_secret(imap_password), created_by_user_id=user_id, updated_by_user_id=user_id, @@ -893,13 +1029,13 @@ def update_mail_server_profile( if is_active is not None: profile.is_active = is_active - next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "password": decrypt_secret(profile.smtp_password_encrypted)}) + next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "username": _profile_username(profile, "smtp"), "password": decrypt_secret(profile.smtp_password_encrypted)}) if clear_imap: next_imap = None elif imap is not None: next_imap = imap elif profile.imap_config: - next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "password": decrypt_secret(profile.imap_password_encrypted)}) + next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "username": _profile_username(profile, "imap"), "password": decrypt_secret(profile.imap_password_encrypted)}) else: next_imap = None @@ -915,17 +1051,22 @@ def update_mail_server_profile( assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap) if smtp is not None: - smtp_payload, smtp_password, supplied = _transport_payload(smtp) + smtp_payload, smtp_username, smtp_password, username_supplied, password_supplied = _transport_payload(smtp) profile.smtp_config = smtp_payload - if supplied: + if username_supplied: + profile.smtp_username = smtp_username + if password_supplied: profile.smtp_password_encrypted = encrypt_secret(smtp_password) if clear_imap: profile.imap_config = None + profile.imap_username = None profile.imap_password_encrypted = None elif imap is not None: - imap_payload, imap_password, supplied = _transport_payload(imap) + imap_payload, imap_username, imap_password, username_supplied, password_supplied = _transport_payload(imap) profile.imap_config = imap_payload - if supplied: + if username_supplied: + profile.imap_username = imap_username + if password_supplied: profile.imap_password_encrypted = encrypt_secret(imap_password) profile.updated_by_user_id = user_id session.add(profile) @@ -933,8 +1074,18 @@ def update_mail_server_profile( return profile +def _profile_username(profile: MailServerProfile, protocol: str) -> str | None: + if protocol == "smtp": + return profile.smtp_username or (profile.smtp_config or {}).get("username") + if protocol == "imap": + return profile.imap_username or (profile.imap_config or {}).get("username") + return None + + def smtp_config_from_profile(profile: MailServerProfile) -> SmtpConfig: payload = dict(profile.smtp_config or {}) + payload.pop("username", None) + payload["username"] = _profile_username(profile, "smtp") payload["password"] = decrypt_secret(profile.smtp_password_encrypted) return SmtpConfig.model_validate(payload) @@ -943,6 +1094,9 @@ def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None: if not profile.imap_config: return None payload = dict(profile.imap_config or {}) + payload.pop("username", None) + payload.pop("enabled", None) + payload["username"] = _profile_username(profile, "imap") payload["password"] = decrypt_secret(profile.imap_password_encrypted) return ImapConfig.model_validate(payload) @@ -952,11 +1106,15 @@ def inherit_profile_credentials(server: dict[str, Any], protocol: str) -> bool: def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]: - config = server.get(protocol) - if not isinstance(config, dict): - return None, None - username = config.get("username") - password = config.get("password") + credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {} + config = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else None + legacy_config = server.get(protocol) if isinstance(server.get(protocol), dict) else None + username = config.get("username") if config is not None else None + password = config.get("password") if config is not None else None + if username in (None, "") and legacy_config is not None: + username = legacy_config.get("username") + if password in (None, "") and legacy_config is not None: + password = legacy_config.get("password") return (str(username) if username not in (None, "") else None, str(password) if password not in (None, "") else None) @@ -967,6 +1125,14 @@ def apply_campaign_credentials(payload: dict[str, Any], server: dict[str, Any], return payload +def _server_config_payload(value: dict[str, Any] | None) -> dict[str, Any]: + payload = dict(value or {}) + payload.pop("username", None) + payload.pop("password", None) + payload.pop("enabled", None) + return payload + + def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]: return { "id": profile.id, @@ -977,8 +1143,12 @@ def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]: "slug": profile.slug, "description": profile.description, "is_active": profile.is_active, - "smtp": dict(profile.smtp_config or {}), - "imap": dict(profile.imap_config or {}) if profile.imap_config else None, + "smtp": _server_config_payload(profile.smtp_config), + "imap": _server_config_payload(profile.imap_config) if profile.imap_config else None, + "credentials": { + "smtp": {"username": _profile_username(profile, "smtp")}, + "imap": {"username": _profile_username(profile, "imap")}, + }, "smtp_password_configured": bool(profile.smtp_password_encrypted), "imap_password_configured": bool(profile.imap_password_encrypted), "created_at": profile.created_at, @@ -1013,22 +1183,36 @@ def get_mail_profile_policy( if group is None or group.tenant_id != tenant_id: raise MailProfileError("Group policy not found") return _policy_from_attr(group.mail_profile_policy) - campaign = session.get(Campaign, scope_id) + campaign = session.get(_campaign_model(), scope_id) if campaign is None or campaign.tenant_id != tenant_id: raise MailProfileError("Campaign policy not found") return _policy_from_attr(campaign.mail_profile_policy) def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None: + parent_limits = parent.allow_lower_level_limits + allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) + if allowed_ids and not parent_limits.get("allowed_profile_ids", True): + raise MailProfileError("Mail profile allow-list is locked by an ancestor policy") + for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"): + if isinstance(normalized.get(key), bool) and not parent_limits.get(key, True): + raise MailProfileError(f"{key} is locked by an ancestor policy") + for kind in ("whitelist", "blacklist"): + rules = normalized.get(kind) or {} + if isinstance(rules, dict): + for key in PROFILE_PATTERN_KEYS: + if _clean_string_list(rules.get(key, [])) and not parent_limits.get(f"{kind}.{key}", True): + raise MailProfileError(f"{kind}.{key} is locked by an ancestor policy") for protocol in ("smtp", "imap"): local = normalized.get(f"{protocol}_credentials") or {} - parent_credentials = _credential_policy_for_protocol(parent, protocol) local_inherit = local.get("inherit") - if isinstance(local_inherit, bool) and not parent_credentials.allow_override and local_inherit != parent_credentials.inherit: + if isinstance(local_inherit, bool) and not parent_limits.get(f"{protocol}_credentials.inherit", True): raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by an ancestor policy") - local_allow_override = local.get("allow_override") - if local_allow_override is True and not parent_credentials.allow_override: - raise MailProfileError(f"{protocol.upper()} credential override cannot be re-enabled below a locked ancestor policy") + local_lower_limits = normalized.get("allow_lower_level_limits") or {} + if isinstance(local_lower_limits, dict): + for key, allowed in local_lower_limits.items(): + if allowed is True and not parent_limits.get(key, True): + raise MailProfileError(f"{key} lower-level limiting cannot be re-enabled below a locked ancestor policy") def set_mail_profile_policy( @@ -1078,7 +1262,7 @@ def set_mail_profile_policy( group.mail_profile_policy = normalized session.add(group) return normalized - campaign = session.get(Campaign, scope_id) + campaign = session.get(_campaign_model(), scope_id) if campaign is None or campaign.tenant_id != tenant_id: raise MailProfileError("Campaign policy not found") campaign.mail_profile_policy = normalized @@ -1086,6 +1270,23 @@ def set_mail_profile_policy( return normalized +def _write_transport_to_server(server: dict[str, Any], protocol: str, payload: dict[str, Any]) -> None: + server_payload = dict(payload) + username = server_payload.pop("username", None) + password = server_payload.pop("password", None) + server_payload.pop("enabled", None) + server[protocol] = server_payload + if username not in (None, "") or password not in (None, ""): + credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {} + protocol_credentials = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {} + if username not in (None, ""): + protocol_credentials["username"] = username + if password not in (None, ""): + protocol_credentials["password"] = password + credentials[protocol] = protocol_credentials + server["credentials"] = credentials + + def materialize_campaign_mail_profile_config( session: Session, *, @@ -1133,13 +1334,13 @@ def materialize_campaign_mail_profile_config( smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json") if not profile_credentials_inherited_for_server(policy, server, "smtp"): smtp_payload = apply_campaign_credentials(smtp_payload, server, "smtp") - resolved_server["smtp"] = smtp_payload + _write_transport_to_server(resolved_server, "smtp", smtp_payload) imap = imap_config_from_profile(profile) if imap is not None: imap_payload = imap.model_dump(mode="json") if not profile_credentials_inherited_for_server(policy, server, "imap"): imap_payload = apply_campaign_credentials(imap_payload, server, "imap") - resolved_server["imap"] = imap_payload + _write_transport_to_server(resolved_server, "imap", imap_payload) else: resolved_server.pop("imap", None) data["server"] = resolved_server diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index eec0a15..f6d27d6 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -82,6 +82,9 @@ manifest = ModuleManifest( metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), ), + capability_factories={ + "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), + }, ) diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index 6c13c47..85ee1e1 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -25,7 +25,7 @@ from govoplan_core.db.session import get_session from govoplan_mail.backend.mail_profiles import ( MailProfileError, create_mail_server_profile, - effective_mail_profile_policy, + effective_mail_profile_policy_for_scope, get_mail_profile_policy, get_mail_server_profile, imap_config_from_profile, @@ -36,6 +36,7 @@ from govoplan_mail.backend.mail_profiles import ( smtp_config_from_profile, update_mail_server_profile, ) +from govoplan_mail.backend.config import ImapConfig, SmtpConfig from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login from govoplan_mail.backend.sending.smtp import test_smtp_login from sqlalchemy.orm import Session @@ -172,7 +173,7 @@ def create_profile( session: Session = Depends(get_session), ): _require_profile_write_scope(principal, payload.scope_type) - if payload.smtp.password or (payload.imap and payload.imap.password): + if payload.credentials_supplied(): _require_profile_credentials_scope(principal, payload.scope_type) try: profile = create_mail_server_profile( @@ -182,8 +183,8 @@ def create_profile( name=payload.name, slug=payload.slug, description=payload.description, - smtp=payload.smtp, - imap=payload.imap, + smtp=payload.smtp_config(), + imap=payload.imap_config(), is_active=payload.is_active, scope_type=payload.scope_type, scope_id=payload.scope_id, @@ -219,8 +220,18 @@ def update_profile( try: profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) _require_profile_write_scope(principal, profile.scope_type or "tenant") - if (payload.smtp and "password" in payload.smtp.model_fields_set) or (payload.imap and "password" in payload.imap.model_fields_set) or payload.clear_imap: + if payload.credentials_supplied() or payload.clear_imap: _require_profile_credentials_scope(principal, profile.scope_type or "tenant") + smtp_config = payload.smtp_config() + if payload.smtp is None and "smtp" in payload.credentials.model_fields_set: + smtp_data = dict(profile.smtp_config or {}) + smtp_data.update(payload.credentials.smtp.model_dump(mode="json", exclude_unset=True)) + smtp_config = SmtpConfig.model_validate(smtp_data) + imap_config = payload.imap_config() + if payload.imap is None and "imap" in payload.credentials.model_fields_set and profile.imap_config: + imap_data = dict(profile.imap_config or {}) + imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True)) + imap_config = ImapConfig.model_validate(imap_data) update_mail_server_profile( session, profile, @@ -230,8 +241,8 @@ def update_profile( slug=payload.slug, description=payload.description if "description" in payload.model_fields_set else None, is_active=payload.is_active, - smtp=payload.smtp, - imap=payload.imap, + smtp=smtp_config, + imap=imap_config, clear_imap=payload.clear_imap, ) session.commit() @@ -273,16 +284,15 @@ def read_mail_profile_policy( _require_policy_read_scope(principal, scope_type) try: policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) - effective = None - effective_sources = [] - if campaign_id: - effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) - effective = effective_policy.as_dict() - effective_sources = effective_policy.source_policies - elif scope_type == "campaign" and scope_id: - effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id) - effective = effective_policy.as_dict() - effective_sources = effective_policy.source_policies + effective_policy = effective_mail_profile_policy_for_scope( + session, + tenant_id=principal.tenant_id, + scope_type=scope_type, + scope_id=scope_id, + campaign_id=campaign_id, + ) + effective = effective_policy.as_dict() + effective_sources = effective_policy.source_policies parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None parent = parent_policy.as_dict() if parent_policy else None parent_sources = parent_policy.source_policies if parent_policy else [] @@ -310,12 +320,14 @@ def write_mail_profile_policy( policy=payload.policy.model_dump(mode="json"), ) session.commit() - effective = None - effective_sources = [] - if scope_type == "campaign" and scope_id: - effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id) - effective = effective_policy.as_dict() - effective_sources = effective_policy.source_policies + effective_policy = effective_mail_profile_policy_for_scope( + session, + tenant_id=principal.tenant_id, + scope_type=scope_type, + scope_id=scope_id, + ) + effective = effective_policy.as_dict() + effective_sources = effective_policy.source_policies parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None parent = parent_policy.as_dict() if parent_policy else None parent_sources = parent_policy.source_policies if parent_policy else [] @@ -379,7 +391,7 @@ def list_profile_imap_folders( if imap is None: raise MailProfileError("Mail-server profile has no IMAP configuration") result = list_imap_folders(imap_config=imap) - folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders] + folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders] return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -397,7 +409,7 @@ def list_profile_mailbox_folders( try: imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) result = list_imap_folders(imap_config=imap) - folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders] + folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders] return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -412,13 +424,14 @@ def list_profile_mailbox_messages( profile_id: str, folder: str = Query(default="INBOX", min_length=1, max_length=255), limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0, le=100000), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_mailbox_read_scope(principal) try: imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) - result = list_imap_messages(imap_config=imap, folder=folder, limit=limit) + result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset) return MailMailboxMessageListResponse( profile_id=profile_id, folder=result.folder, @@ -426,6 +439,8 @@ def list_profile_mailbox_messages( port=result.port, security=result.security, total_count=result.total_count, + offset=result.offset, + limit=result.limit, messages=[_mailbox_summary_response(message) for message in result.messages], ) except MailProfileError as exc: @@ -551,7 +566,7 @@ def list_imap_folder_settings( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials") try: result = list_imap_folders(imap_config=payload) - folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders] + folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders] return MailImapFolderListResponse( ok=True, host=result.host, diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index 52b83b6..03aa125 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -3,9 +3,9 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator -from govoplan_mail.backend.config import ImapConfig, SmtpConfig +from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials class MailSmtpTestRequest(SmtpConfig): @@ -15,7 +15,50 @@ class MailSmtpTestRequest(SmtpConfig): class MailImapTestRequest(ImapConfig): """IMAP settings supplied directly from the WebUI mail settings form.""" - enabled: bool = True + +class MailTransportCredentialsPayload(TransportCredentials): + """Credentials supplied separately from saved server settings.""" + + +class MailServerProfileCredentialsPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + smtp: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload) + imap: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload) + + +def _normalize_profile_transport_payload(value: object) -> object: + if not isinstance(value, dict): + return value + data = dict(value) + credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {} + credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)} + for protocol in ("smtp", "imap"): + transport = data.get(protocol) + if not isinstance(transport, dict): + continue + next_transport = dict(transport) + next_credentials = dict(credentials.get(protocol) or {}) + for field in ("username", "password"): + if field in next_transport and field not in next_credentials: + next_credentials[field] = next_transport[field] + next_transport.pop(field, None) + next_transport.pop("enabled", None) + data[protocol] = next_transport + if next_credentials: + credentials[protocol] = next_credentials + if credentials: + data["credentials"] = credentials + return data + + +def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]: + payload = server.model_dump(mode="json") + if "username" in credentials.model_fields_set: + payload["username"] = credentials.username + if "password" in credentials.model_fields_set: + payload["password"] = credentials.password + return payload MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"] @@ -25,7 +68,6 @@ class MailCredentialPolicyPayload(BaseModel): model_config = ConfigDict(extra="forbid") inherit: bool | None = None - allow_override: bool | None = None class MailProfilePolicyPayload(BaseModel): @@ -39,6 +81,7 @@ class MailProfilePolicyPayload(BaseModel): imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) whitelist: dict[str, list[str]] = Field(default_factory=dict) blacklist: dict[str, list[str]] = Field(default_factory=dict) + allow_lower_level_limits: dict[str, bool] = Field(default_factory=dict) class MailProfilePolicyUpdateRequest(BaseModel): @@ -52,6 +95,7 @@ class PolicySourceStepResponse(BaseModel): scope_id: str | None = None label: str applied_fields: list[str] = Field(default_factory=list) + policy: dict[str, Any] = Field(default_factory=dict) class MailProfilePolicyResponse(BaseModel): @@ -73,8 +117,29 @@ class MailServerProfileCreateRequest(BaseModel): is_active: bool = True scope_type: MailProfileScope = "tenant" scope_id: str | None = None - smtp: SmtpConfig - imap: ImapConfig | None = None + smtp: SmtpServerConfig + imap: ImapServerConfig | None = None + credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload) + + @model_validator(mode="before") + @classmethod + def normalize_legacy_transport_payload(cls, value: object) -> object: + return _normalize_profile_transport_payload(value) + + def smtp_config(self) -> SmtpConfig: + return SmtpConfig.model_validate(_merge_transport_credentials(self.smtp, self.credentials.smtp)) + + def imap_config(self) -> ImapConfig | None: + if self.imap is None: + return None + return ImapConfig.model_validate(_merge_transport_credentials(self.imap, self.credentials.imap)) + + def credentials_supplied(self) -> bool: + return any( + field in credentials.model_fields_set + for credentials in (self.credentials.smtp, self.credentials.imap) + for field in ("username", "password") + ) class MailServerProfileUpdateRequest(BaseModel): @@ -84,10 +149,35 @@ class MailServerProfileUpdateRequest(BaseModel): slug: str | None = Field(default=None, max_length=100) description: str | None = None is_active: bool | None = None - smtp: SmtpConfig | None = None - imap: ImapConfig | None = None + smtp: SmtpServerConfig | None = None + imap: ImapServerConfig | None = None + credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload) clear_imap: bool = False + @model_validator(mode="before") + @classmethod + def normalize_legacy_transport_payload(cls, value: object) -> object: + return _normalize_profile_transport_payload(value) + + def smtp_config(self) -> SmtpConfig | None: + if self.smtp is None and "smtp" not in self.credentials.model_fields_set: + return None + server = self.smtp or SmtpServerConfig() + return SmtpConfig.model_validate(_merge_transport_credentials(server, self.credentials.smtp)) + + def imap_config(self) -> ImapConfig | None: + if self.imap is None and "imap" not in self.credentials.model_fields_set: + return None + server = self.imap or ImapServerConfig() + return ImapConfig.model_validate(_merge_transport_credentials(server, self.credentials.imap)) + + def credentials_supplied(self) -> bool: + return any( + field in credentials.model_fields_set + for credentials in (self.credentials.smtp, self.credentials.imap) + for field in ("username", "password") + ) + class MailServerProfileResponse(BaseModel): id: str @@ -100,6 +190,7 @@ class MailServerProfileResponse(BaseModel): is_active: bool smtp: dict[str, Any] imap: dict[str, Any] | None = None + credentials: dict[str, Any] = Field(default_factory=dict) smtp_password_configured: bool = False imap_password_configured: bool = False created_at: datetime @@ -123,6 +214,8 @@ class MailConnectionTestResponse(BaseModel): class MailImapFolderResponse(BaseModel): name: str flags: list[str] = Field(default_factory=list) + message_count: int | None = None + unseen_count: int | None = None class MailImapFolderListResponse(BaseModel): @@ -171,6 +264,8 @@ class MailMailboxMessageListResponse(BaseModel): port: int | None = None security: str | None = None total_count: int = 0 + offset: int = 0 + limit: int = 50 messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py index e74e6b8..42ea420 100644 --- a/src/govoplan_mail/backend/sending/imap.py +++ b/src/govoplan_mail/backend/sending/imap.py @@ -50,6 +50,8 @@ class ImapLoginTestResult: class ImapMailboxInfo: name: str flags: list[str] + message_count: int | None = None + unseen_count: int | None = None @dataclass(frozen=True, slots=True) @@ -111,6 +113,8 @@ class ImapMailboxMessageListResult: folder: str messages: list[ImapMailboxMessageSummary] total_count: int + offset: int + limit: int @dataclass(frozen=True, slots=True) @@ -133,8 +137,6 @@ class ImapAppendResult: def _require_imap_config(config: ImapConfig) -> tuple[str, int]: - if not config.enabled: - raise ImapConfigurationError("IMAP is disabled") if not config.host: raise ImapConfigurationError("IMAP host is required") if not config.port: @@ -311,7 +313,12 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: host, port = _require_imap_config(imap_config) if is_mock_imap_host(imap_config.host): - folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS] + records = list_records(limit=500) + folders = [] + for item in MOCK_IMAP_FOLDERS: + name = str(item["name"]) + count = sum(1 for record in records if _mock_folder_matches(record, name)) + folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None)) return ImapFolderListResult( host=host, port=port, @@ -334,7 +341,8 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: continue name, flags = extracted parsed.append((name, flags)) - folders.append(ImapMailboxInfo(name=name, flags=sorted(flags))) + message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name) + folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count)) return ImapFolderListResult( host=host, @@ -350,6 +358,31 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: pass +def _has_folder_flag(flags: set[str], flag: str) -> bool: + wanted = flag.casefold().lstrip("\\") + return any(item.casefold().lstrip("\\") == wanted for item in flags) + + +def _quote_mailbox_name(name: str) -> str: + return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + + +def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]: + try: + typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)") + except Exception: + return None, None + if typ != "OK": + return None, None + text = " ".join(_decode_item(item) for item in data or []) + messages_match = re.search(r"\bMESSAGES\s+(\d+)", text, re.IGNORECASE) + unseen_match = re.search(r"\bUNSEEN\s+(\d+)", text, re.IGNORECASE) + return ( + int(messages_match.group(1)) if messages_match else None, + int(unseen_match.group(1)) if unseen_match else None, + ) + + def _parse_message(raw: bytes) -> EmailMessage: return BytesParser(policy=policy.default).parsebytes(raw) @@ -393,6 +426,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]: return attachments +def _message_summary_from_headers(*, uid: str, folder: str, headers: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary: + message = _parse_message(headers.rstrip() + b"\r\n\r\n") + return ImapMailboxMessageSummary( + uid=uid, + folder=folder, + subject=_header_text(message, "Subject"), + from_header=_header_text(message, "From"), + to_header=_header_text(message, "To"), + cc_header=_header_text(message, "Cc"), + date=_header_text(message, "Date"), + message_id=_header_text(message, "Message-ID"), + flags=flags, + size_bytes=size_bytes, + body_preview=None, + attachment_count=0, + ) + + def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary: message = _parse_message(raw) attachments = _attachment_infos(message) @@ -434,6 +485,14 @@ def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[s ) +def _parse_fetch_metadata(metadata: str) -> tuple[str | None, list[str], int | None]: + uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE) + size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE) + flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE) + flags = flags_match.group(1).split() if flags_match else [] + return uid_match.group(1) if uid_match else None, flags, int(size_match.group(1)) if size_match else None + + def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]: metadata_parts: list[str] = [] raw: bytes | None = None @@ -445,24 +504,38 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str else: metadata_parts.append(_decode_item(item)) metadata = " ".join(part for part in metadata_parts if part) - uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE) - size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE) - flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE) - flags = flags_match.group(1).split() if flags_match else [] + uid, flags, size_bytes = _parse_fetch_metadata(metadata) if raw is None: raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True) - return ( - uid_match.group(1) if uid_match else None, - flags, - int(size_match.group(1)) if size_match else None, - raw, - ) + return uid, flags, size_bytes, raw -def _select_readonly(client: imaplib.IMAP4, folder: str) -> None: +def _parse_fetch_sequence(metadata: str) -> str | None: + match = re.match(r"\s*(\d+)\s+\(", metadata) + return match.group(1) if match else None + + +def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -> list[tuple[str | None, str | None, list[str], int | None, bytes]]: + parts: list[tuple[str | None, str | None, list[str], int | None, bytes]] = [] + for item in data or []: + if not isinstance(item, tuple) or not isinstance(item[1], bytes): + continue + metadata = _decode_item(item[0]) + sequence = _parse_fetch_sequence(metadata) + uid, flags, size_bytes = _parse_fetch_metadata(metadata) + parts.append((sequence, uid, flags, size_bytes, item[1])) + return parts + + +def _select_readonly(client: imaplib.IMAP4, folder: str) -> int: typ, data = client.select(folder, readonly=True) if typ != "OK": raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) + selected_count = _decode_item(data[0] if data else None).strip() + try: + return max(0, int(selected_count)) + except ValueError: + return 0 def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]: @@ -473,6 +546,50 @@ def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[st return fetched_uid or str(uid), flags, size_bytes, raw +def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]: + typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])") + if typ != "OK": + raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True) + fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data) + if not fetched_uid: + raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True) + return fetched_uid, flags, size_bytes, raw + + +def _sequence_set(sequences: list[str]) -> str: + sequence_numbers = sorted({int(sequence) for sequence in sequences if str(sequence).isdigit()}) + if not sequence_numbers: + return ",".join(str(sequence) for sequence in sequences) + expected_range = list(range(sequence_numbers[0], sequence_numbers[-1] + 1)) + if sequence_numbers == expected_range: + return f"{sequence_numbers[0]}:{sequence_numbers[-1]}" + return ",".join(str(sequence) for sequence in sequence_numbers) + + +def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]: + if not sequences: + return [] + typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])") + if typ != "OK": + raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True) + + by_sequence: dict[str, ImapMailboxMessageSummary] = {} + for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data): + if not sequence or not fetched_uid: + continue + by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes) + + summaries: list[ImapMailboxMessageSummary] = [] + for sequence in sequences: + summary = by_sequence.get(str(sequence)) + if summary is not None: + summaries.append(summary) + continue + fetched_uid, flags, size_bytes, raw = _fetch_message_by_sequence(client, sequence) + summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)) + return summaries + + def _mock_record_folder(record: dict[str, Any]) -> str: folder = str(record.get("folder") or "").strip() if folder: @@ -495,14 +612,16 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes: return "\n".join(lines).encode("utf-8", errors="replace") -def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> ImapMailboxMessageListResult: +def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult: """List mailbox messages without mutating read/unread state.""" host, port = _require_imap_config(imap_config) folder = (folder or "INBOX").strip() or "INBOX" limit = max(1, min(limit, 100)) + offset = max(0, offset) if is_mock_imap_host(imap_config.host): records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] + page_records = records[offset:offset + limit] messages = [ _message_summary_from_raw( uid=str(record.get("id") or ""), @@ -511,23 +630,16 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: flags=["\\Seen"] if record.get("kind") == "imap_append" else [], size_bytes=int(record.get("size_bytes") or 0) or None, ) - for record in records[:limit] + for record in page_records ] - return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records)) + return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit) client = _open_imap(imap_config) try: - _select_readonly(client, folder) - typ, data = client.uid("search", None, "ALL") - if typ != "OK": - raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True) - uid_bytes = data[0] if data else b"" - uids = _decode_item(uid_bytes).split() - messages: list[ImapMailboxMessageSummary] = [] - for uid in reversed(uids[-limit:]): - fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid) - messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)) - return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids)) + total_count = _select_readonly(client, folder) + page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit) + messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder) + return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit) finally: try: client.logout() @@ -535,6 +647,14 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: pass +def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]: + if total_count <= 0 or offset >= total_count: + return [] + end = total_count - offset + start = max(1, end - limit + 1) + return [str(sequence) for sequence in range(end, start - 1, -1)] + + def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult: """Fetch one message body using BODY.PEEK in a read-only selected mailbox.""" diff --git a/tests/test_imap_parser.py b/tests/test_imap_parser.py new file mode 100644 index 0000000..0606f9c --- /dev/null +++ b/tests/test_imap_parser.py @@ -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() diff --git a/webui/package.json b/webui/package.json index 57e300e..fe50766 100644 --- a/webui/package.json +++ b/webui/package.json @@ -24,5 +24,11 @@ "@govoplan/core-webui": { "optional": true } + }, + "scripts": { + "test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-policy-validation.test.js" + }, + "devDependencies": { + "typescript": "^5.7.2" } } diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 524d7ed..4a9af93 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -14,10 +14,19 @@ export type MailSmtpTestPayload = { }; export type MailImapTestPayload = MailSmtpTestPayload & { - enabled?: boolean; sent_folder?: string | null; }; +export type MailTransportCredentialsPayload = { + username?: string | null; + password?: string | null; +}; + +export type MailServerProfileCredentialsPayload = { + smtp?: MailTransportCredentialsPayload; + imap?: MailTransportCredentialsPayload; +}; + export type MailConnectionTestResponse = { ok: boolean; protocol: "smtp" | "imap"; @@ -31,6 +40,8 @@ export type MailConnectionTestResponse = { export type MailImapFolderResponse = { name: string; flags?: string[]; + message_count?: number | null; + unseen_count?: number | null; }; export type MailImapFolderListResponse = { @@ -59,6 +70,7 @@ export type MailMailboxMessageSummary = { from_header?: string | null; to_header?: string | null; cc_header?: string | null; + bcc_header?: string | null; date?: string | null; message_id?: string | null; flags?: string[]; @@ -81,6 +93,8 @@ export type MailMailboxMessageListResponse = { port?: number | null; security?: MailSecurity | string | null; total_count?: number; + offset?: number; + limit?: number; messages: MailMailboxMessageSummary[]; }; @@ -104,6 +118,7 @@ export type MailServerProfile = { is_active: boolean; smtp: MailSmtpTestPayload; imap?: MailImapTestPayload | null; + credentials?: MailServerProfileCredentialsPayload | null; smtp_password_configured: boolean; imap_password_configured: boolean; created_at: string; @@ -123,9 +138,30 @@ export const mailProfilePatternKeys = [ export type MailProfilePatternKey = typeof mailProfilePatternKeys[number]; export type MailProfilePatternRules = Partial>; +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>; + export type MailCredentialPolicy = { inherit?: boolean | null; - allow_override?: boolean | null; }; export type MailProfilePolicy = { @@ -137,6 +173,7 @@ export type MailProfilePolicy = { imap_credentials?: MailCredentialPolicy | null; whitelist?: MailProfilePatternRules | null; blacklist?: MailProfilePatternRules | null; + allow_lower_level_limits?: MailProfilePolicyLimitPermissions | null; }; export type PolicySourceStep = { @@ -144,6 +181,7 @@ export type PolicySourceStep = { scope_id?: string | null; label: string; applied_fields?: string[]; + policy?: MailProfilePolicy | null; }; export type MailProfilePolicyResponse = { @@ -165,6 +203,7 @@ export type MailServerProfilePayload = { scope_id?: string | null; smtp: MailSmtpTestPayload; imap?: MailImapTestPayload | null; + credentials?: MailServerProfileCredentialsPayload | null; }; export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise { @@ -244,9 +283,10 @@ export async function listMailboxMessages( settings: ApiSettings, profileId: string, folder = "INBOX", - limit = 50 + limit = 50, + offset = 0 ): Promise { - const params = new URLSearchParams({ folder, limit: String(limit) }); + const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) }); return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`); } diff --git a/webui/src/components/Button.tsx b/webui/src/components/Button.tsx deleted file mode 100644 index 710440c..0000000 --- a/webui/src/components/Button.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Button } from "@govoplan/core-webui"; -export default Button; diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx deleted file mode 100644 index e1eeb17..0000000 --- a/webui/src/components/Card.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Card } from "@govoplan/core-webui"; -export default Card; diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx deleted file mode 100644 index eeb657c..0000000 --- a/webui/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ConfirmDialog } from "@govoplan/core-webui"; -export default ConfirmDialog; diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx deleted file mode 100644 index e0b9e8b..0000000 --- a/webui/src/components/Dialog.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Dialog } from "@govoplan/core-webui"; -export default Dialog; diff --git a/webui/src/components/DismissibleAlert.tsx b/webui/src/components/DismissibleAlert.tsx deleted file mode 100644 index 09ad137..0000000 --- a/webui/src/components/DismissibleAlert.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { DismissibleAlert } from "@govoplan/core-webui"; -export default DismissibleAlert; diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx deleted file mode 100644 index 59b6792..0000000 --- a/webui/src/components/FormField.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { FormField } from "@govoplan/core-webui"; -export default FormField; diff --git a/webui/src/components/LoadingFrame.tsx b/webui/src/components/LoadingFrame.tsx deleted file mode 100644 index c007f26..0000000 --- a/webui/src/components/LoadingFrame.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoadingFrame } from "@govoplan/core-webui"; -export default LoadingFrame; diff --git a/webui/src/components/LoadingIndicator.tsx b/webui/src/components/LoadingIndicator.tsx deleted file mode 100644 index e2e63cc..0000000 --- a/webui/src/components/LoadingIndicator.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoadingIndicator } from "@govoplan/core-webui"; -export default LoadingIndicator; diff --git a/webui/src/components/MetricCard.tsx b/webui/src/components/MetricCard.tsx deleted file mode 100644 index 5af7d1f..0000000 --- a/webui/src/components/MetricCard.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { MetricCard } from "@govoplan/core-webui"; -export default MetricCard; diff --git a/webui/src/components/PageTitle.tsx b/webui/src/components/PageTitle.tsx deleted file mode 100644 index 2a58990..0000000 --- a/webui/src/components/PageTitle.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { PageTitle } from "@govoplan/core-webui"; -export default PageTitle; diff --git a/webui/src/components/StatusBadge.tsx b/webui/src/components/StatusBadge.tsx deleted file mode 100644 index 13347b8..0000000 --- a/webui/src/components/StatusBadge.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { StatusBadge } from "@govoplan/core-webui"; -export default StatusBadge; diff --git a/webui/src/components/Stepper.tsx b/webui/src/components/Stepper.tsx deleted file mode 100644 index fc27f21..0000000 --- a/webui/src/components/Stepper.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Stepper } from "@govoplan/core-webui"; -export default Stepper; diff --git a/webui/src/components/ToggleSwitch.tsx b/webui/src/components/ToggleSwitch.tsx deleted file mode 100644 index cae981b..0000000 --- a/webui/src/components/ToggleSwitch.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ToggleSwitch } from "@govoplan/core-webui"; -export default ToggleSwitch; diff --git a/webui/src/components/email/EmailAddressInput.tsx b/webui/src/components/email/EmailAddressInput.tsx deleted file mode 100644 index 40f5009..0000000 --- a/webui/src/components/email/EmailAddressInput.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { EmailAddressInput } from "@govoplan/core-webui"; -export default EmailAddressInput; diff --git a/webui/src/components/help/FieldLabel.tsx b/webui/src/components/help/FieldLabel.tsx deleted file mode 100644 index b205494..0000000 --- a/webui/src/components/help/FieldLabel.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { FieldLabel } from "@govoplan/core-webui"; -export default FieldLabel; diff --git a/webui/src/components/help/InlineHelp.tsx b/webui/src/components/help/InlineHelp.tsx deleted file mode 100644 index d2cb6bf..0000000 --- a/webui/src/components/help/InlineHelp.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { InlineHelp } from "@govoplan/core-webui"; -export default InlineHelp; diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 3e9822c..db6833f 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; -import { EffectivePolicyBlock, EffectivePolicyValue, MailServerSettingsPanel, PolicyLockedHint, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, type PolicySourcePathItem } from "@govoplan/core-webui"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { FieldLabel, MailServerSettingsPanel, PolicyLockedHint, PolicySourcePath, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailServerSecurity, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, type PolicySourcePathItem } from "@govoplan/core-webui"; import { Pencil, Plus, Trash2 } from "lucide-react"; import type { ApiSettings } from "../../types"; import { @@ -10,6 +10,7 @@ import { listMailProfileImapFolders, listMailServerProfiles, mailProfilePatternKeys, + mailProfilePolicyLimitKeys, updateMailProfilePolicy, testImapSettings, testMailProfileImap, @@ -21,6 +22,7 @@ import { type MailProfilePatternKey, type MailProfilePatternRules, type MailProfilePolicy, + type MailProfilePolicyLimitKey, type MailProfileScope, type MailSecurity, type MailServerProfile, @@ -28,14 +30,14 @@ import { type MailServerProfileUpdatePayload, type MailSmtpTestPayload } from "../../api/mail"; -import Button from "../../components/Button"; -import Card from "../../components/Card"; -import ConfirmDialog from "../../components/ConfirmDialog"; +import { validateMailPolicy } from "./mailPolicyValidation"; +import { Button } from "@govoplan/core-webui"; +import { Card } from "@govoplan/core-webui"; +import { ConfirmDialog } from "@govoplan/core-webui"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; -import Dialog from "../../components/Dialog"; -import DismissibleAlert from "../../components/DismissibleAlert"; -import FormField from "../../components/FormField"; - +import { Dialog } from "@govoplan/core-webui"; +import { DismissibleAlert } from "@govoplan/core-webui"; +import { FormField } from "@govoplan/core-webui"; export type MailProfileTargetOption = { id: string; label: string; @@ -53,7 +55,6 @@ type ProfileDraft = { smtpUsername: string; smtpPassword: string; smtpTimeout: string; - imapEnabled: boolean; imapHost: string; imapPort: string; imapSecurity: MailSecurity; @@ -94,9 +95,8 @@ type MailProfilePolicyEditorProps = { type EditingProfile = MailServerProfile | "new" | null; type PolicyFlagValue = "inherit" | "allow" | "deny"; type CredentialInheritanceValue = "inherit" | "profile" | "local"; -type CredentialOverrideValue = "inherit" | "allow" | "lock"; -const securityOptions: MailSecurity[] = ["plain", "tls", "starttls"]; +const securityOptions = mailServerSecurityOptions as readonly MailSecurity[]; const patternLabels: Record = { smtp_hosts: "SMTP hostnames", @@ -114,7 +114,8 @@ const blankPolicy: MailProfilePolicy = { smtp_credentials: {}, imap_credentials: {}, whitelist: {}, - blacklist: {} + blacklist: {}, + allow_lower_level_limits: {} }; export function MailProfileScopeManager({ @@ -138,10 +139,14 @@ export function MailProfileScopeManager({ const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); + const [profileEffectivePolicy, setProfileEffectivePolicy] = useState(null); const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign"; + const targetSelectionRequired = requiresTarget && !scopeId; + const hasSelectableTarget = targetOptions.length > 0; const activeScopeId = scopeId || selectedTargetId || null; const scopeReady = !requiresTarget || Boolean(activeScopeId); + const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`; useEffect(() => { if (scopeId) { @@ -150,18 +155,32 @@ export function MailProfileScopeManager({ } if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) { setSelectedTargetId(targetOptions[0].id); + return; } + if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId(""); }, [scopeId, selectedTargetId, targetOptions]); - useEffect(() => { void loadProfiles(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + useEffect(() => { void loadProfiles(); }, [activeScopeId, scopeReady, scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); async function loadProfiles() { setLoading(true); setError(""); + if (!scopeReady) { + setProfiles([]); + setProfileEffectivePolicy(null); + setLoading(false); + return; + } try { - setProfiles(await listMailServerProfiles(settings, true)); + const [nextProfiles, policyResponse] = await Promise.all([ + listMailServerProfiles(settings, true), + getMailProfilePolicy(settings, scopeType, activeScopeId) + ]); + setProfiles(nextProfiles); + setProfileEffectivePolicy(policyResponse?.effective_policy ?? null); } catch (err) { setProfiles([]); + setProfileEffectivePolicy(null); setError(errorMessage(err)); } finally { setLoading(false); @@ -242,11 +261,12 @@ export function MailProfileScopeManager({ return (
- {targetOptions.length > 0 && !scopeId && ( + {targetSelectionRequired && (
- setSelectedTargetId(event.target.value)} disabled={loading || busy || !hasSelectableTarget}> + {!hasSelectableTarget && } {targetOptions.map((option) => )} @@ -254,36 +274,40 @@ export function MailProfileScopeManager({ )} - - - - - -
- } - > -
- profile.id} - emptyText={scopeReady ? "No profiles in this scope." : `Select a ${targetLabel.toLowerCase()} first.`} + {scopeReady && ( + <> + -
-
+ + + + +
+ } + > +
+ profile.id} + emptyText="No profiles in this scope." + /> +
+ + + )} {error && {error}} {success && {success}} @@ -296,7 +320,7 @@ export function MailProfileScopeManager({ closeDisabled={busy} footer={<>} > - + (null); const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign"; const scopeReady = !requiresTarget || Boolean(scopeId); @@ -375,7 +400,7 @@ export function MailProfilePolicyEditor({ setError(""); setSuccess(""); try { - const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicy(policy), scopeId); + const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicyForSave(policy, parentPolicy, scopeType), scopeId); setPolicy(normalizePolicy(response.policy)); setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null); setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null); @@ -393,20 +418,26 @@ export function MailProfilePolicyEditor({ () => profileCandidatesForPolicy(profiles, scopeType, scopeId, ownerUserId, ownerGroupId), [ownerGroupId, ownerUserId, profiles, scopeId, scopeType] ); + const isSystem = scopeType === "system"; + const displayPolicy = useMemo(() => isSystem ? concreteSystemPolicy(policy) : policy, [isSystem, policy]); const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []); const disabled = locked || busy || loading || !canWrite || !scopeReady; const parentAllowedProfileIds = parentPolicy?.allowed_profile_ids?.length ? new Set(parentPolicy.allowed_profile_ids) : null; const parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false; const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false; const parentBlocksCampaignProfiles = parentPolicy?.allow_campaign_profiles === false; - const smtpCredentialLocked = parentPolicy?.smtp_credentials?.allow_override === false; - const imapCredentialLocked = parentPolicy?.imap_credentials?.allow_override === false; + const smtpCredentialLocked = !parentAllowsMailLimit("smtp_credentials.inherit"); + const imapCredentialLocked = !parentAllowsMailLimit("imap_credentials.inherit"); + const showAllowColumn = scopeType !== "campaign"; + const showEffectiveColumn = !isSystem; + const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids"); const blockedProfileDefinitions = [ parentBlocksUserProfiles ? "user" : "", parentBlocksGroupProfiles ? "group" : "", parentBlocksCampaignProfiles ? "campaign-local settings" : "" ].filter(Boolean).join(", "); const lockedCredentialKinds = [smtpCredentialLocked ? "SMTP" : "", imapCredentialLocked ? "IMAP" : ""].filter(Boolean).join(" and "); + const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType); function patchPolicy(patch: Partial) { setPolicy((current) => normalizePolicy({ ...current, ...patch })); @@ -436,6 +467,33 @@ export function MailProfilePolicyEditor({ patchPolicy({ [kind]: nextRules }); } + function parentAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean { + return !parentPolicy || parentPolicy.allow_lower_level_limits?.[key] !== false; + } + + function localAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean { + const localValue = policy.allow_lower_level_limits?.[key]; + if (localValue !== undefined) return localValue && parentAllowsMailLimit(key); + return parentAllowsMailLimit(key); + } + + function setAllowLowerLevelLimit(key: MailProfilePolicyLimitKey, allowed: boolean) { + patchPolicy({ allow_lower_level_limits: { ...(policy.allow_lower_level_limits ?? {}), [key]: allowed } }); + } + + function lowerLevelLimitToggle(key: MailProfilePolicyLimitKey, label: ReactNode = "Allow override"): ReactNode | undefined { + if (!showAllowColumn) return undefined; + const parentLocked = !parentAllowsMailLimit(key); + return ( + setAllowLowerLevelLimit(key, checked)} + label={label} + /> + ); + } + return (

Profile allow-list

- +
+ {lowerLevelLimitToggle("allowed_profile_ids")} + +

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

{candidateProfiles.map((profile) => ( ))} @@ -471,53 +532,91 @@ export function MailProfilePolicyEditor({

Lower-level mail definitions

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

Credential inheritance

-
- setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} /> - setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} /> - setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} /> - setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} /> -
+ + setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />} + effective={showEffectiveColumn ? (effectivePolicy ? credentialInheritanceLabel(effectivePolicy.smtp_credentials) : "Loading...") : undefined} + allowControl={lowerLevelLimitToggle("smtp_credentials.inherit")} + effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("smtp_credentials", effectivePolicyPath) : undefined} + /> + setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />} + effective={showEffectiveColumn ? (effectivePolicy ? credentialInheritanceLabel(effectivePolicy.imap_credentials) : "Loading...") : undefined} + allowControl={lowerLevelLimitToggle("imap_credentials.inherit")} + effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("imap_credentials", effectivePolicyPath) : undefined} + /> + {lockedCredentialKinds && {lockedCredentialKinds} credential inheritance is locked by an ancestor policy.}

Wildcard rules

-
-
-

Whitelist

- {mailProfilePatternKeys.map((key) => setPattern("whitelist", key, text)} />)} -
-
-

Blacklist

- {mailProfilePatternKeys.map((key) => setPattern("blacklist", key, text)} />)} +
+
+ Policy target + Whitelist + Blacklist + {showAllowColumn && Lower levels}
+ {mailProfilePatternKeys.map((key) => ( +
+
+ {patternLabels[key]} +
+ setPattern("whitelist", key, text)} /> + setPattern("blacklist", key, text)} /> + {showAllowColumn && ( +
+ {lowerLevelLimitToggle(`whitelist.${key}` as MailProfilePolicyLimitKey, "Whitelist")} + {lowerLevelLimitToggle(`blacklist.${key}` as MailProfilePolicyLimitKey, "Blacklist")} +
+ )} +
+ ))}
- {effectivePolicy && ( - 0 ? effectivePolicySources : mailPolicySourcePath(scopeType)} - className="mail-policy-section mail-policy-effective" - gridClassName="mail-policy-effective-grid" - > - - - - - - - + {showEffectiveColumn && effectivePolicy && ( +
+

Policy path

+ +

Effective values are shown in the table rows above.

+
)}
@@ -531,7 +630,8 @@ function ProfileForm({ editing, busy, canWrite, - canManageCredentials + canManageCredentials, + effectivePolicy }: { settings: ApiSettings; draft: ProfileDraft; @@ -540,6 +640,7 @@ function ProfileForm({ busy: boolean; canWrite: boolean; canManageCredentials: boolean; + effectivePolicy?: MailProfilePolicy | null; }) { const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); @@ -549,10 +650,13 @@ function ProfileForm({ const disabled = busy || !canWrite; const credentialDisabled = disabled || !canManageCredentials; const existingProfile = editing !== "new" ? editing : null; - const existingHasImap = Boolean(existingProfile?.imap); - const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled); + const draftHasImap = hasDraftImapSettings(draft); const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured); const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured); + const policyMessages = useMemo(() => validateMailPolicy(effectivePolicy, { + smtpHost: draft.smtpHost, + imapHost: draft.imapHost + }), [draft.imapHost, draft.smtpHost, effectivePolicy]); useEffect(() => { setSmtpTestResult(null); @@ -566,8 +670,6 @@ function ProfileForm({ ...draft, smtpHost: patch.host !== undefined ? String(patch.host ?? "") : draft.smtpHost, smtpPort: patch.port !== undefined ? String(patch.port ?? "") : draft.smtpPort, - smtpUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.smtpUsername, - smtpPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.smtpPassword, smtpSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "starttls"), "starttls") : draft.smtpSecurity, smtpTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.smtpTimeout }); @@ -578,21 +680,37 @@ function ProfileForm({ ...draft, imapHost: patch.host !== undefined ? String(patch.host ?? "") : draft.imapHost, imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort, - imapUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.imapUsername, - imapPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.imapPassword, imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity, imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder, imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout }); } + + + function patchSmtpCredentials(patch: Partial) { + setDraft({ + ...draft, + smtpUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.smtpUsername, + smtpPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.smtpPassword + }); + } + + function patchImapCredentials(patch: Partial) { + setDraft({ + ...draft, + imapUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.imapUsername, + imapPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.imapPassword + }); + } + async function runSmtpTest() { setMailActionState("smtp"); setSmtpTestResult(null); try { setSmtpTestResult(useSavedSmtpTest && existingProfile ? await testMailProfileSmtp(settings, existingProfile.id) - : await testSmtpSettings(settings, smtpPayload(draft, false))); + : await testSmtpSettings(settings, rawSmtpPayload(draft, false))); } catch (err) { setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} }); } finally { @@ -601,13 +719,13 @@ function ProfileForm({ } async function runImapTest() { - if (!draft.imapEnabled) return; + if (!draftHasImap) return; setMailActionState("imap"); setImapTestResult(null); try { setImapTestResult(useSavedImapTest && existingProfile ? await testMailProfileImap(settings, existingProfile.id) - : await testImapSettings(settings, imapPayload(draft, false))); + : await testImapSettings(settings, rawImapPayload(draft, false))); } catch (err) { setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} }); } finally { @@ -616,13 +734,13 @@ function ProfileForm({ } async function runFolderLookup() { - if (!draft.imapEnabled) return; + if (!draftHasImap) return; setMailActionState("folders"); setFolderResult(null); try { setFolderResult(useSavedImapTest && existingProfile ? await listMailProfileImapFolders(settings, existingProfile.id) - : await listImapFolders(settings, imapPayload(draft, false))); + : await listImapFolders(settings, rawImapPayload(draft, false))); } catch (err) { setFolderResult({ ok: false, message: errorMessage(err), folders: [] }); } finally { @@ -632,7 +750,7 @@ function ProfileForm({ function useDetectedSentFolder() { const folder = folderResult?.detected_sent_folder; - if (!folder || disabled || !draft.imapEnabled) return; + if (!folder || disabled || !draftHasImap) return; setDraft({ ...draft, imapSentFolder: folder }); } @@ -645,22 +763,29 @@ function ProfileForm({