diff --git a/src/govoplan_mail/backend/config.py b/src/govoplan_mail/backend/config.py index 204f4ac..33e9525 100644 --- a/src/govoplan_mail/backend/config.py +++ b/src/govoplan_mail/backend/config.py @@ -8,6 +8,7 @@ from govoplan_core.mail.config import ( StrictModel, TransportCredentials, TransportSecurity, + normalize_split_transport_credentials, ) __all__ = [ @@ -18,4 +19,5 @@ __all__ = [ "StrictModel", "TransportCredentials", "TransportSecurity", + "normalize_split_transport_credentials", ] diff --git a/src/govoplan_mail/backend/db/models.py b/src/govoplan_mail/backend/db/models.py index 4a62815..bc2a501 100644 --- a/src/govoplan_mail/backend/db/models.py +++ b/src/govoplan_mail/backend/db/models.py @@ -3,7 +3,9 @@ from __future__ import annotations import uuid from typing import Any -from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from govoplan_core.db.base import Base, TimestampMixin @@ -50,3 +52,49 @@ class MailProfilePolicy(Base, TimestampMixin): scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class MailMailboxFolderIndex(Base, TimestampMixin): + __tablename__ = "mail_mailbox_folder_index" + __table_args__ = ( + UniqueConstraint("profile_id", "folder", name="uq_mail_mailbox_folder_index_profile_folder"), + Index("ix_mail_mailbox_folder_index_tenant_profile", "tenant_id", "profile_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + profile_id: Mapped[str] = mapped_column(ForeignKey("mail_server_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + folder: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + flags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + message_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + unseen_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + uidvalidity: Mapped[str | None] = mapped_column(String(255), nullable=True) + indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + message_indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + +class MailMailboxMessageIndex(Base, TimestampMixin): + __tablename__ = "mail_mailbox_message_index" + __table_args__ = ( + UniqueConstraint("profile_id", "folder", "uid", name="uq_mail_mailbox_message_index_profile_folder_uid"), + Index("ix_mail_mailbox_message_index_page", "tenant_id", "profile_id", "folder", "sort_position"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + profile_id: Mapped[str] = mapped_column(ForeignKey("mail_server_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + folder: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + uid: Mapped[str] = mapped_column(String(255), nullable=False) + uid_int: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False, index=True) + sort_position: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False, index=True) + subject: Mapped[str | None] = mapped_column(Text, nullable=True) + from_header: Mapped[str | None] = mapped_column(Text, nullable=True) + to_header: Mapped[str | None] = mapped_column(Text, nullable=True) + cc_header: Mapped[str | None] = mapped_column(Text, nullable=True) + date: Mapped[str | None] = mapped_column(String(255), nullable=True) + message_id: Mapped[str | None] = mapped_column(Text, nullable=True) + flags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + body_preview: Mapped[str | None] = mapped_column(Text, nullable=True) + attachment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index af09797..43930d7 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -496,15 +496,7 @@ def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, s 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 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 and lower_limits.get("allow_user_profiles", True): - policy.allow_user_profiles = 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 and lower_limits.get("allow_campaign_profiles", True): - policy.allow_campaign_profiles = False + _merge_profile_definition_policy(policy, normalized, lower_limits) _merge_credential_policy( policy.smtp_credentials, normalized.get("smtp_credentials") or {}, @@ -519,19 +511,43 @@ def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, s lower_limits=lower_limits, inherit_limit_key="imap_credentials.inherit", ) + _merge_pattern_policy(policy, normalized, lower_limits) + _merge_lower_level_limits(policy, normalized, lower_limits) + + +def _policy_limit_enabled(lower_limits: dict[str, bool], key: str) -> bool: + return lower_limits.get(key, True) + + +def _merge_profile_definition_policy(policy: EffectiveMailProfilePolicy, normalized: dict[str, Any], lower_limits: dict[str, bool]) -> None: + allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) + if allowed_ids and _policy_limit_enabled(lower_limits, "allowed_profile_ids"): + policy.allowed_profile_id_sets.append(set(allowed_ids)) + if normalized.get("allow_user_profiles") is False and _policy_limit_enabled(lower_limits, "allow_user_profiles"): + policy.allow_user_profiles = False + if normalized.get("allow_group_profiles") is False and _policy_limit_enabled(lower_limits, "allow_group_profiles"): + policy.allow_group_profiles = False + if normalized.get("allow_campaign_profiles") is False and _policy_limit_enabled(lower_limits, "allow_campaign_profiles"): + policy.allow_campaign_profiles = False + + +def _merge_pattern_policy(policy: EffectiveMailProfilePolicy, normalized: dict[str, Any], lower_limits: dict[str, bool]) -> None: 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 and lower_limits.get(f"whitelist.{key}", True): + if allow_patterns and _policy_limit_enabled(lower_limits, f"whitelist.{key}"): policy.whitelist_groups.setdefault(key, []).append(allow_patterns) deny_patterns = _clean_string_list(blacklist.get(key, [])) - if deny_patterns and lower_limits.get(f"blacklist.{key}", True): + if deny_patterns and _policy_limit_enabled(lower_limits, f"blacklist.{key}"): policy.blacklist_patterns.setdefault(key, []).extend(deny_patterns) + + +def _merge_lower_level_limits(policy: EffectiveMailProfilePolicy, normalized: dict[str, Any], lower_limits: dict[str, bool]) -> None: 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)) + key: _policy_limit_enabled(lower_limits, key) and local_lower_limits.get(key, _policy_limit_enabled(lower_limits, key)) for key in MAIL_POLICY_LIMIT_KEYS } @@ -1187,49 +1203,81 @@ 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 {}), "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 {}), "username": _profile_username(profile, "imap"), "password": decrypt_secret(profile.imap_password_encrypted)}) - else: - next_imap = None + next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap) + _assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap) + _apply_profile_transport_update(profile, smtp=smtp, imap=imap, clear_imap=clear_imap) + profile.updated_by_user_id = user_id + session.add(profile) + session.flush() + return profile + +def _next_profile_transport_state( + profile: MailServerProfile, + *, + smtp: SmtpConfig | None, + imap: ImapConfig | None, + clear_imap: bool, +) -> tuple[SmtpConfig, ImapConfig | None]: + next_smtp = smtp or smtp_config_from_profile(profile) + if clear_imap: + return next_smtp, None + if imap is not None: + return next_smtp, imap + return next_smtp, imap_config_from_profile(profile) + + +def _assert_profile_transport_allowed( + session: Session, + *, + tenant_id: str, + profile: MailServerProfile, + smtp: SmtpConfig, + imap: ImapConfig | None, +) -> None: scope_type = _profile_scope_type(profile) scope_id = _profile_scope_id(profile) if scope_type == "campaign": - assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, campaign_id=scope_id) + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, campaign_id=scope_id) elif scope_type == "user": - assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, owner_user_id=scope_id) + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, owner_user_id=scope_id) elif scope_type == "group": - assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, owner_group_id=scope_id) + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, owner_group_id=scope_id) elif scope_type == "tenant": - assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap) + assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap) + +def _apply_profile_transport_update( + profile: MailServerProfile, + *, + smtp: SmtpConfig | None, + imap: ImapConfig | None, + clear_imap: bool, +) -> None: if smtp is not None: - smtp_payload, smtp_username, smtp_password, username_supplied, password_supplied = _transport_payload(smtp) - profile.smtp_config = smtp_payload - if username_supplied: - profile.smtp_username = smtp_username - if password_supplied: - profile.smtp_password_encrypted = encrypt_secret(smtp_password) + _apply_profile_transport_payload(profile, "smtp", smtp) if clear_imap: profile.imap_config = None profile.imap_username = None profile.imap_password_encrypted = None elif imap is not None: - imap_payload, imap_username, imap_password, username_supplied, password_supplied = _transport_payload(imap) - profile.imap_config = imap_payload + _apply_profile_transport_payload(profile, "imap", imap) + + +def _apply_profile_transport_payload(profile: MailServerProfile, protocol: str, config: SmtpConfig | ImapConfig) -> None: + payload, username, password, username_supplied, password_supplied = _transport_payload(config) + if protocol == "smtp": + profile.smtp_config = payload if username_supplied: - profile.imap_username = imap_username + profile.smtp_username = username if password_supplied: - profile.imap_password_encrypted = encrypt_secret(imap_password) - profile.updated_by_user_id = user_id - session.add(profile) - session.flush() - return profile + profile.smtp_password_encrypted = encrypt_secret(password) + return + profile.imap_config = payload + if username_supplied: + profile.imap_username = username + if password_supplied: + profile.imap_password_encrypted = encrypt_secret(password) def _profile_username(profile: MailServerProfile, protocol: str) -> str | None: @@ -1346,29 +1394,50 @@ def get_mail_profile_policy( def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None: - parent_limits = parent.allow_lower_level_limits + violations = _policy_parent_lock_violations(parent.allow_lower_level_limits, normalized) + if violations: + raise MailProfileError(_policy_parent_lock_message(violations[0])) + + +def _policy_parent_lock_violations(parent_limits: dict[str, bool], normalized: dict[str, Any]) -> list[str]: + violations: list[str] = [] 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") + if allowed_ids and not _policy_limit_enabled(parent_limits, "allowed_profile_ids"): + violations.append("allowed_profile_ids") 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") + if isinstance(normalized.get(key), bool) and not _policy_limit_enabled(parent_limits, key): + violations.append(key) 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") + limit_key = f"{kind}.{key}" + if _clean_string_list(rules.get(key, [])) and not _policy_limit_enabled(parent_limits, limit_key): + violations.append(limit_key) for protocol in ("smtp", "imap"): local = normalized.get(f"{protocol}_credentials") or {} local_inherit = local.get("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") + limit_key = f"{protocol}_credentials.inherit" + if isinstance(local_inherit, bool) and not _policy_limit_enabled(parent_limits, limit_key): + violations.append(limit_key) 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") + if allowed is True and not _policy_limit_enabled(parent_limits, key): + violations.append(f"allow_lower_level_limits.{key}") + return violations + + +def _policy_parent_lock_message(field: str) -> str: + if field == "allowed_profile_ids": + return "Mail profile allow-list is locked by an ancestor policy" + if field.startswith("allow_lower_level_limits."): + key = field.removeprefix("allow_lower_level_limits.") + return f"{key} lower-level limiting cannot be re-enabled below a locked ancestor policy" + if field.endswith("_credentials.inherit"): + protocol = field.split("_", 1)[0].upper() + return f"{protocol} credential inheritance is locked by an ancestor policy" + return f"{field} is locked by an ancestor policy" def set_mail_profile_policy( diff --git a/src/govoplan_mail/backend/mailbox_index.py b/src/govoplan_mail/backend/mailbox_index.py new file mode 100644 index 0000000..2ba416a --- /dev/null +++ b/src/govoplan_mail/backend/mailbox_index.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from threading import Lock + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.security.time import ensure_aware_utc, utc_now +from govoplan_mail.backend.db.models import MailMailboxFolderIndex, MailMailboxMessageIndex +from govoplan_mail.backend.sending.imap import ImapFolderListResult, ImapMailboxInfo, ImapMailboxMessageListResult, ImapMailboxMessageSummary + +MAILBOX_INDEX_TTL_SECONDS = 30 + +_refresh_lock = Lock() +_refreshing_keys: set[tuple[str, str, str]] = set() + + +@dataclass(frozen=True, slots=True) +class CachedFolderList: + folders: list[ImapMailboxInfo] + indexed_at: datetime | None + stale: bool + + +@dataclass(frozen=True, slots=True) +class CachedMessagePage: + folder: str + messages: list[ImapMailboxMessageSummary] + total_count: int + offset: int + limit: int + uidvalidity: str | None + indexed_at: datetime | None + stale: bool + + +def begin_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> bool: + key = (tenant_id, profile_id, folder) + with _refresh_lock: + if key in _refreshing_keys: + return False + _refreshing_keys.add(key) + return True + + +def finish_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> None: + with _refresh_lock: + _refreshing_keys.discard((tenant_id, profile_id, folder)) + + +def cache_mailbox_folders( + session: Session, + *, + tenant_id: str, + profile_id: str, + result: ImapFolderListResult, + indexed_at: datetime | None = None, +) -> None: + indexed_at = indexed_at or utc_now() + existing = { + row.folder: row + for row in session.query(MailMailboxFolderIndex) + .filter(MailMailboxFolderIndex.tenant_id == tenant_id, MailMailboxFolderIndex.profile_id == profile_id) + .all() + } + for folder in result.folders: + row = existing.get(folder.name) + if row is None: + row = MailMailboxFolderIndex(tenant_id=tenant_id, profile_id=profile_id, folder=folder.name) + row.flags = list(folder.flags or []) + row.message_count = folder.message_count + row.unseen_count = folder.unseen_count + row.indexed_at = indexed_at + session.add(row) + + +def cache_mailbox_messages( + session: Session, + *, + tenant_id: str, + profile_id: str, + result: ImapMailboxMessageListResult, + indexed_at: datetime | None = None, +) -> None: + indexed_at = indexed_at or utc_now() + session.flush() + folder_row = ( + session.query(MailMailboxFolderIndex) + .filter( + MailMailboxFolderIndex.tenant_id == tenant_id, + MailMailboxFolderIndex.profile_id == profile_id, + MailMailboxFolderIndex.folder == result.folder, + ) + .one_or_none() + ) + if folder_row is None: + folder_row = MailMailboxFolderIndex(tenant_id=tenant_id, profile_id=profile_id, folder=result.folder) + folder_row.message_count = result.total_count + folder_row.uidvalidity = result.uidvalidity + folder_row.message_indexed_at = indexed_at + session.add(folder_row) + + uids = [message.uid for message in result.messages] + if result.total_count <= 0: + ( + session.query(MailMailboxMessageIndex) + .filter( + MailMailboxMessageIndex.tenant_id == tenant_id, + MailMailboxMessageIndex.profile_id == profile_id, + MailMailboxMessageIndex.folder == result.folder, + ) + .delete(synchronize_session=False) + ) + return + window_count = max(0, min(result.limit, result.total_count - result.offset)) + if window_count: + stale_query = session.query(MailMailboxMessageIndex).filter( + MailMailboxMessageIndex.tenant_id == tenant_id, + MailMailboxMessageIndex.profile_id == profile_id, + MailMailboxMessageIndex.folder == result.folder, + MailMailboxMessageIndex.sort_position >= result.offset, + MailMailboxMessageIndex.sort_position < result.offset + window_count, + ) + if uids: + stale_query = stale_query.filter(MailMailboxMessageIndex.uid.notin_(uids)) + for row in stale_query.all(): + session.delete(row) + existing = {} + if uids: + existing = { + row.uid: row + for row in session.query(MailMailboxMessageIndex) + .filter( + MailMailboxMessageIndex.tenant_id == tenant_id, + MailMailboxMessageIndex.profile_id == profile_id, + MailMailboxMessageIndex.folder == result.folder, + MailMailboxMessageIndex.uid.in_(uids), + ) + .all() + } + for index, message in enumerate(result.messages): + row = existing.get(message.uid) + if row is None: + row = MailMailboxMessageIndex( + tenant_id=tenant_id, + profile_id=profile_id, + folder=result.folder, + uid=message.uid, + ) + row.uid_int = _uid_int(message.uid) + row.sort_position = result.offset + index + row.subject = message.subject + row.from_header = message.from_header + row.to_header = message.to_header + row.cc_header = message.cc_header + row.date = message.date + row.message_id = message.message_id + row.flags = list(message.flags or []) + row.size_bytes = message.size_bytes + row.body_preview = message.body_preview + row.attachment_count = message.attachment_count + row.indexed_at = indexed_at + session.add(row) + + +def cached_mailbox_folders( + session: Session, + *, + tenant_id: str, + profile_id: str, + max_age_seconds: int = MAILBOX_INDEX_TTL_SECONDS, +) -> CachedFolderList | None: + rows = ( + session.query(MailMailboxFolderIndex) + .filter( + MailMailboxFolderIndex.tenant_id == tenant_id, + MailMailboxFolderIndex.profile_id == profile_id, + MailMailboxFolderIndex.indexed_at.isnot(None), + ) + .order_by(MailMailboxFolderIndex.folder.asc()) + .all() + ) + if not rows: + return None + indexed_at = max((row.indexed_at for row in rows if row.indexed_at), default=None) + return CachedFolderList( + folders=[ + ImapMailboxInfo(name=row.folder, flags=list(row.flags or []), message_count=row.message_count, unseen_count=row.unseen_count) + for row in rows + ], + indexed_at=indexed_at, + stale=_is_stale(indexed_at, max_age_seconds=max_age_seconds), + ) + + +def cached_mailbox_message_page( + session: Session, + *, + tenant_id: str, + profile_id: str, + folder: str, + limit: int, + offset: int, + max_age_seconds: int = MAILBOX_INDEX_TTL_SECONDS, +) -> CachedMessagePage | None: + folder_row = ( + session.query(MailMailboxFolderIndex) + .filter( + MailMailboxFolderIndex.tenant_id == tenant_id, + MailMailboxFolderIndex.profile_id == profile_id, + MailMailboxFolderIndex.folder == folder, + ) + .one_or_none() + ) + if folder_row is None or folder_row.message_indexed_at is None: + return None + total_count = folder_row.message_count + if total_count is None: + total_count = int( + session.query(func.count(MailMailboxMessageIndex.id)) + .filter( + MailMailboxMessageIndex.tenant_id == tenant_id, + MailMailboxMessageIndex.profile_id == profile_id, + MailMailboxMessageIndex.folder == folder, + ) + .scalar() + or 0 + ) + rows = ( + session.query(MailMailboxMessageIndex) + .filter( + MailMailboxMessageIndex.tenant_id == tenant_id, + MailMailboxMessageIndex.profile_id == profile_id, + MailMailboxMessageIndex.folder == folder, + ) + .order_by(MailMailboxMessageIndex.sort_position.asc(), MailMailboxMessageIndex.uid_int.desc(), MailMailboxMessageIndex.uid.desc()) + .offset(offset) + .limit(limit) + .all() + ) + expected_count = max(0, min(limit, total_count - offset)) + if len(rows) < expected_count: + return None + indexed_at = min((row.indexed_at for row in rows if row.indexed_at), default=folder_row.message_indexed_at) + return CachedMessagePage( + folder=folder, + messages=[_message_from_index(row) for row in rows], + total_count=total_count, + offset=offset, + limit=limit, + uidvalidity=folder_row.uidvalidity, + indexed_at=indexed_at, + stale=_is_stale(indexed_at, max_age_seconds=max_age_seconds), + ) + + +def _message_from_index(row: MailMailboxMessageIndex) -> ImapMailboxMessageSummary: + return ImapMailboxMessageSummary( + uid=row.uid, + folder=row.folder, + subject=row.subject, + from_header=row.from_header, + to_header=row.to_header, + cc_header=row.cc_header, + date=row.date, + message_id=row.message_id, + flags=list(row.flags or []), + size_bytes=row.size_bytes, + body_preview=row.body_preview, + attachment_count=row.attachment_count, + ) + + +def _uid_int(uid: str) -> int: + try: + return int(str(uid)) + except ValueError: + return 0 + + +def _is_stale(indexed_at: datetime | None, *, max_age_seconds: int) -> bool: + aware = ensure_aware_utc(indexed_at) + if aware is None: + return True + return (utc_now() - aware).total_seconds() > max_age_seconds diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index d5f148b..da7fde1 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -72,7 +72,7 @@ ROLE_TEMPLATES = ( def _mail_router(context: ModuleContext): from govoplan_mail.backend.runtime import configure_runtime - configure_runtime(settings=context.settings) + configure_runtime(registry=context.registry, settings=context.settings) from fastapi import APIRouter from govoplan_mail.backend.router import router @@ -91,7 +91,7 @@ manifest = ModuleManifest( name="Mail", version="0.1.8", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), - optional_dependencies=("campaigns",), + optional_dependencies=("campaigns", "addresses"), provides_interfaces=( ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"), ), @@ -102,6 +102,12 @@ manifest = ModuleManifest( version_max_exclusive="0.2.0", optional=True, ), + ModuleInterfaceRequirement( + name="addresses.lookup", + version_min="0.1.0", + version_max_exclusive="0.2.0", + optional=True, + ), ), permissions=PERMISSIONS, route_factory=_mail_router, @@ -120,6 +126,8 @@ manifest = ModuleManifest( retirement_provider=drop_table_retirement_provider( mail_models.MailServerProfile, mail_models.MailProfilePolicy, + mail_models.MailMailboxFolderIndex, + mail_models.MailMailboxMessageIndex, label="Mail", ), retirement_notes="Destructive retirement drops mail-owned database tables after the installer captures a database snapshot.", @@ -128,6 +136,8 @@ manifest = ModuleManifest( persistent_table_uninstall_guard( mail_models.MailServerProfile, mail_models.MailProfilePolicy, + mail_models.MailMailboxFolderIndex, + mail_models.MailMailboxMessageIndex, label="Mail", ), ), diff --git a/src/govoplan_mail/backend/migrations/dev_versions/4e5f708192ab_mail_mailbox_index.py b/src/govoplan_mail/backend/migrations/dev_versions/4e5f708192ab_mail_mailbox_index.py new file mode 100644 index 0000000..821c2f2 --- /dev/null +++ b/src/govoplan_mail/backend/migrations/dev_versions/4e5f708192ab_mail_mailbox_index.py @@ -0,0 +1,112 @@ +"""mail mailbox index + +Revision ID: 4e5f708192ab +Revises: 3d4e5f708192 +Create Date: 2026-07-14 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "4e5f708192ab" +down_revision = "3d4e5f708192" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "mail_mailbox_folder_index" not in tables: + op.create_table( + "mail_mailbox_folder_index", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("profile_id", sa.String(length=36), nullable=False), + sa.Column("folder", sa.String(length=255), nullable=False), + sa.Column("flags", sa.JSON(), nullable=False), + sa.Column("message_count", sa.Integer(), nullable=True), + sa.Column("unseen_count", sa.Integer(), nullable=True), + sa.Column("uidvalidity", sa.String(length=255), nullable=True), + sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["mail_server_profiles.id"], name=op.f("fk_mail_mailbox_folder_index_profile_id_mail_server_profiles"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_folder_index")), + sa.UniqueConstraint("profile_id", "folder", name="uq_mail_mailbox_folder_index_profile_folder"), + ) + op.create_index(op.f("ix_mail_mailbox_folder_index_folder"), "mail_mailbox_folder_index", ["folder"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), "mail_mailbox_folder_index", ["indexed_at"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_profile_id"), "mail_mailbox_folder_index", ["profile_id"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), "mail_mailbox_folder_index", ["tenant_id"], unique=False) + op.create_index("ix_mail_mailbox_folder_index_tenant_profile", "mail_mailbox_folder_index", ["tenant_id", "profile_id"], unique=False) + else: + columns = {column["name"] for column in inspector.get_columns("mail_mailbox_folder_index")} + if "message_indexed_at" not in columns: + op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False) + if "mail_mailbox_message_index" not in tables: + op.create_table( + "mail_mailbox_message_index", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("profile_id", sa.String(length=36), nullable=False), + sa.Column("folder", sa.String(length=255), nullable=False), + sa.Column("uid", sa.String(length=255), nullable=False), + sa.Column("uid_int", sa.BigInteger(), nullable=False), + sa.Column("sort_position", sa.BigInteger(), nullable=False), + sa.Column("subject", sa.Text(), nullable=True), + sa.Column("from_header", sa.Text(), nullable=True), + sa.Column("to_header", sa.Text(), nullable=True), + sa.Column("cc_header", sa.Text(), nullable=True), + sa.Column("date", sa.String(length=255), nullable=True), + sa.Column("message_id", sa.Text(), nullable=True), + sa.Column("flags", sa.JSON(), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=True), + sa.Column("body_preview", sa.Text(), nullable=True), + sa.Column("attachment_count", sa.Integer(), nullable=False), + sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["mail_server_profiles.id"], name=op.f("fk_mail_mailbox_message_index_profile_id_mail_server_profiles"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_message_index")), + sa.UniqueConstraint("profile_id", "folder", "uid", name="uq_mail_mailbox_message_index_profile_folder_uid"), + ) + op.create_index(op.f("ix_mail_mailbox_message_index_folder"), "mail_mailbox_message_index", ["folder"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_indexed_at"), "mail_mailbox_message_index", ["indexed_at"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_profile_id"), "mail_mailbox_message_index", ["profile_id"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_tenant_id"), "mail_mailbox_message_index", ["tenant_id"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_uid_int"), "mail_mailbox_message_index", ["uid_int"], unique=False) + op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False) + else: + columns = {column["name"] for column in inspector.get_columns("mail_mailbox_message_index")} + if "sort_position" not in columns: + op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0")) + op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "mail_mailbox_message_index" in tables: + op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_uid_int"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_tenant_id"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_profile_id"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_indexed_at"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_folder"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index") + op.drop_table("mail_mailbox_message_index") + if "mail_mailbox_folder_index" in tables: + op.drop_index("ix_mail_mailbox_folder_index_tenant_profile", table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_profile_id"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_folder"), table_name="mail_mailbox_folder_index") + op.drop_table("mail_mailbox_folder_index") diff --git a/src/govoplan_mail/backend/migrations/versions/4e5f708192ab_v018_mail_mailbox_index.py b/src/govoplan_mail/backend/migrations/versions/4e5f708192ab_v018_mail_mailbox_index.py new file mode 100644 index 0000000..3044f40 --- /dev/null +++ b/src/govoplan_mail/backend/migrations/versions/4e5f708192ab_v018_mail_mailbox_index.py @@ -0,0 +1,112 @@ +"""v0.1.8 mail mailbox index + +Revision ID: 4e5f708192ab +Revises: 3d4e5f708192 +Create Date: 2026-07-14 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "4e5f708192ab" +down_revision = "3d4e5f708192" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "mail_mailbox_folder_index" not in tables: + op.create_table( + "mail_mailbox_folder_index", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("profile_id", sa.String(length=36), nullable=False), + sa.Column("folder", sa.String(length=255), nullable=False), + sa.Column("flags", sa.JSON(), nullable=False), + sa.Column("message_count", sa.Integer(), nullable=True), + sa.Column("unseen_count", sa.Integer(), nullable=True), + sa.Column("uidvalidity", sa.String(length=255), nullable=True), + sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["mail_server_profiles.id"], name=op.f("fk_mail_mailbox_folder_index_profile_id_mail_server_profiles"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_folder_index")), + sa.UniqueConstraint("profile_id", "folder", name="uq_mail_mailbox_folder_index_profile_folder"), + ) + op.create_index(op.f("ix_mail_mailbox_folder_index_folder"), "mail_mailbox_folder_index", ["folder"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), "mail_mailbox_folder_index", ["indexed_at"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_profile_id"), "mail_mailbox_folder_index", ["profile_id"], unique=False) + op.create_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), "mail_mailbox_folder_index", ["tenant_id"], unique=False) + op.create_index("ix_mail_mailbox_folder_index_tenant_profile", "mail_mailbox_folder_index", ["tenant_id", "profile_id"], unique=False) + else: + columns = {column["name"] for column in inspector.get_columns("mail_mailbox_folder_index")} + if "message_indexed_at" not in columns: + op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False) + if "mail_mailbox_message_index" not in tables: + op.create_table( + "mail_mailbox_message_index", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("profile_id", sa.String(length=36), nullable=False), + sa.Column("folder", sa.String(length=255), nullable=False), + sa.Column("uid", sa.String(length=255), nullable=False), + sa.Column("uid_int", sa.BigInteger(), nullable=False), + sa.Column("sort_position", sa.BigInteger(), nullable=False), + sa.Column("subject", sa.Text(), nullable=True), + sa.Column("from_header", sa.Text(), nullable=True), + sa.Column("to_header", sa.Text(), nullable=True), + sa.Column("cc_header", sa.Text(), nullable=True), + sa.Column("date", sa.String(length=255), nullable=True), + sa.Column("message_id", sa.Text(), nullable=True), + sa.Column("flags", sa.JSON(), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=True), + sa.Column("body_preview", sa.Text(), nullable=True), + sa.Column("attachment_count", sa.Integer(), nullable=False), + sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["mail_server_profiles.id"], name=op.f("fk_mail_mailbox_message_index_profile_id_mail_server_profiles"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_message_index")), + sa.UniqueConstraint("profile_id", "folder", "uid", name="uq_mail_mailbox_message_index_profile_folder_uid"), + ) + op.create_index(op.f("ix_mail_mailbox_message_index_folder"), "mail_mailbox_message_index", ["folder"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_indexed_at"), "mail_mailbox_message_index", ["indexed_at"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_profile_id"), "mail_mailbox_message_index", ["profile_id"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_tenant_id"), "mail_mailbox_message_index", ["tenant_id"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False) + op.create_index(op.f("ix_mail_mailbox_message_index_uid_int"), "mail_mailbox_message_index", ["uid_int"], unique=False) + op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False) + else: + columns = {column["name"] for column in inspector.get_columns("mail_mailbox_message_index")} + if "sort_position" not in columns: + op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0")) + op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "mail_mailbox_message_index" in tables: + op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_uid_int"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_tenant_id"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_profile_id"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_indexed_at"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_folder"), table_name="mail_mailbox_message_index") + op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index") + op.drop_table("mail_mailbox_message_index") + if "mail_mailbox_folder_index" in tables: + op.drop_index("ix_mail_mailbox_folder_index_tenant_profile", table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_profile_id"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), table_name="mail_mailbox_folder_index") + op.drop_index(op.f("ix_mail_mailbox_folder_index_folder"), table_name="mail_mailbox_folder_index") + op.drop_table("mail_mailbox_folder_index") diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index bfcd0c1..88ff57f 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -1,13 +1,20 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query, status +import dataclasses +from types import SimpleNamespace +from typing import Any + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from sqlalchemy import func, or_ from sqlalchemy.orm import Session from govoplan_mail.backend.schemas import ( + MailAddressLookupCandidate, + MailAddressLookupResponse, MailConnectionTestResponse, MailImapFolderListResponse, MailImapFolderResponse, + MailMailboxBootstrapResponse, MailImapTestRequest, MailMailboxAttachmentResponse, MailMailboxMessageDetailResponse, @@ -33,7 +40,15 @@ from govoplan_core.core.change_sequence import ( sequence_watermark_is_expired, ) from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint -from govoplan_core.db.session import get_session +from govoplan_core.db.session import get_database, get_session +from govoplan_mail.backend.mailbox_index import ( + begin_mailbox_refresh, + cache_mailbox_folders, + cache_mailbox_messages, + cached_mailbox_folders, + cached_mailbox_message_page, + finish_mailbox_refresh, +) from govoplan_mail.backend.mail_profiles import ( MailProfileError, create_mail_server_profile, @@ -49,7 +64,8 @@ from govoplan_mail.backend.mail_profiles import ( 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.runtime import get_registry +from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, load_imap_mailbox_bootstrap, test_imap_login from govoplan_mail.backend.sending.smtp import test_smtp_login router = APIRouter(prefix="/mail", tags=["mail"]) @@ -62,6 +78,39 @@ MAIL_PROFILE_RESOURCE = "mail_profile" MAIL_POLICY_RESOURCE = "mail_profile_policy" MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1" DEFAULT_MAILBOX_MESSAGE_LIMIT = 50 +CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" + + +def _capability_payload(value: object) -> dict[str, Any]: + if dataclasses.is_dataclass(value): + return dataclasses.asdict(value) + if isinstance(value, dict): + return dict(value) + payload: dict[str, Any] = {} + for key in ( + "contact_id", + "address_book_id", + "display_name", + "email", + "email_label", + "organization", + "role_title", + "tags", + "source_kind", + "source_ref", + "source_revision", + "provenance", + ): + if hasattr(value, key): + payload[key] = getattr(value, key) + return payload + + +def _registry_capability(name: str) -> object | None: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name): + return None + return registry.capability(name) def _require_scope(principal: ApiPrincipal, scope: str) -> None: @@ -338,6 +387,132 @@ def _next_mailbox_cursor( ) +def _mailbox_folder_response( + result, + *, + from_cache: bool = False, + refreshing: bool = False, + indexed_at=None, +) -> MailImapFolderListResponse: + 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, + from_cache=from_cache, + refreshing=refreshing, + indexed_at=indexed_at, + ) + + +def _mailbox_messages_response( + *, + profile_id: str, + folder: str, + host: str | None, + port: int | None, + security: str | None, + total_count: int, + offset: int, + limit: int, + cursor: str | None, + next_cursor: str | None, + cursor_stable: bool, + full: bool, + messages, + from_cache: bool = False, + refreshing: bool = False, + indexed_at=None, +) -> MailMailboxMessageListResponse: + return MailMailboxMessageListResponse( + profile_id=profile_id, + folder=folder, + host=host, + port=port, + security=security, + total_count=total_count, + offset=offset, + limit=limit, + cursor=cursor, + next_cursor=next_cursor, + cursor_stable=cursor_stable, + full=full, + from_cache=from_cache, + refreshing=refreshing, + indexed_at=indexed_at, + messages=[_mailbox_summary_response(message) for message in messages], + ) + + +def _schedule_mailbox_refresh( + background_tasks: BackgroundTasks, + *, + tenant_id: str, + profile_id: str, + folder: str, + limit: int, + offset: int = 0, + include_folder_status: bool = False, +) -> bool: + if not begin_mailbox_refresh(tenant_id, profile_id, folder): + return False + background_tasks.add_task( + _refresh_mailbox_index_task, + tenant_id=tenant_id, + profile_id=profile_id, + folder=folder, + limit=limit, + offset=offset, + include_folder_status=include_folder_status, + ) + return True + + +def _refresh_mailbox_index_task( + *, + tenant_id: str, + profile_id: str, + folder: str, + limit: int, + offset: int, + include_folder_status: bool, +) -> None: + try: + with get_database().session() as session: + imap = _imap_config_for_profile(session, tenant_id=tenant_id, profile_id=profile_id) + result = load_imap_mailbox_bootstrap( + imap_config=imap, + folder=folder, + limit=limit, + offset=offset, + include_folder_status=include_folder_status, + ) + cache_mailbox_folders(session, tenant_id=tenant_id, profile_id=profile_id, result=result.folders) + cache_mailbox_messages(session, tenant_id=tenant_id, profile_id=profile_id, result=result.messages) + session.commit() + except Exception: + # Background refresh is opportunistic. Foreground requests will surface + # concrete IMAP errors when no usable cache is available. + pass + finally: + finish_mailbox_refresh(tenant_id, profile_id, folder) + + +def _cached_folder_selection(folders, requested: str, detected_sent_folder: str | None = None) -> str: + names = {folder.name for folder in folders} + if requested in names: + return requested + if "INBOX" in names: + return "INBOX" + if detected_sent_folder and detected_sent_folder in names: + return detected_sent_folder + return folders[0].name if folders else requested + + def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str): profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True) imap = imap_config_from_profile(profile) @@ -378,6 +553,24 @@ def _full_mail_settings_delta_response( ) +@router.get("/address-lookup", response_model=MailAddressLookupResponse) +def lookup_mail_addresses( + query: str = Query(min_length=1), + limit: int = Query(default=25, ge=1, le=100), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> MailAddressLookupResponse: + _require_scope(principal, "mail:profile:use") + capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP) + if capability is None or not hasattr(capability, "lookup"): + return MailAddressLookupResponse(available=False, candidates=[]) + candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit) + return MailAddressLookupResponse( + available=True, + candidates=[MailAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates], + ) + + @router.get("/settings/delta", response_model=MailSettingsDeltaResponse) def mail_settings_delta( scope_type: str = Query(default="tenant"), @@ -711,8 +904,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, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders] - return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder) + return _mailbox_folder_response(result) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: @@ -722,15 +914,169 @@ def list_profile_imap_folders( @router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse) def list_profile_mailbox_folders( profile_id: str, + background_tasks: BackgroundTasks, + include_status: bool = Query(default=False), + refresh: bool = False, 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_folders(imap_config=imap) - 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) + if not include_status and not refresh: + cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id) + if cached is not None: + refreshing = cached.stale and _schedule_mailbox_refresh( + background_tasks, + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder="INBOX", + limit=DEFAULT_MAILBOX_MESSAGE_LIMIT, + include_folder_status=include_status, + ) + result = SimpleNamespace( + host=imap.host, + port=imap.port, + security=imap.security.value, + folders=cached.folders, + detected_sent_folder=None, + ) + return _mailbox_folder_response(result, from_cache=True, refreshing=refreshing, indexed_at=cached.indexed_at) + result = list_imap_folders(imap_config=imap, include_status=include_status) + cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result) + session.commit() + return _mailbox_folder_response(result) + except MailProfileError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ImapConfigurationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + except ImapAppendError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + +@router.get("/profiles/{profile_id}/mailbox/bootstrap", response_model=MailMailboxBootstrapResponse) +def bootstrap_profile_mailbox( + profile_id: str, + background_tasks: BackgroundTasks, + folder: str = Query(default="INBOX", min_length=1, max_length=255), + limit: int = Query(default=DEFAULT_MAILBOX_MESSAGE_LIMIT, ge=1, le=100), + offset: int = Query(default=0, ge=0, le=100000), + refresh: bool = False, + include_status: bool = Query(default=False), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +) -> MailMailboxBootstrapResponse: + _require_mailbox_read_scope(principal) + try: + imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) + if not refresh: + cached_folders = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id) + if cached_folders is not None: + selected_folder = _cached_folder_selection(cached_folders.folders, folder) + cached_messages = cached_mailbox_message_page( + session, + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=selected_folder, + limit=limit, + offset=offset, + ) + if cached_messages is not None: + refreshing = (cached_folders.stale or cached_messages.stale) and _schedule_mailbox_refresh( + background_tasks, + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=selected_folder, + limit=limit, + offset=offset, + include_folder_status=include_status, + ) + folder_result = SimpleNamespace( + host=imap.host, + port=imap.port, + security=imap.security.value, + folders=cached_folders.folders, + detected_sent_folder=None, + ) + next_cursor, cursor_stable = _next_mailbox_cursor( + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=cached_messages.folder, + limit=cached_messages.limit, + offset=cached_messages.offset, + total_count=cached_messages.total_count, + uidvalidity=cached_messages.uidvalidity, + messages=cached_messages.messages, + ) + return MailMailboxBootstrapResponse( + profile_id=profile_id, + folder=cached_messages.folder, + folders=_mailbox_folder_response( + folder_result, + from_cache=True, + refreshing=refreshing, + indexed_at=cached_folders.indexed_at, + ), + messages=_mailbox_messages_response( + profile_id=profile_id, + folder=cached_messages.folder, + host=imap.host, + port=imap.port, + security=imap.security.value, + total_count=cached_messages.total_count, + offset=cached_messages.offset, + limit=cached_messages.limit, + cursor=None, + next_cursor=next_cursor, + cursor_stable=cursor_stable, + full=not cursor_stable, + messages=cached_messages.messages, + from_cache=True, + refreshing=refreshing, + indexed_at=cached_messages.indexed_at, + ), + ) + + result = load_imap_mailbox_bootstrap( + imap_config=imap, + folder=folder, + limit=limit, + offset=offset, + include_folder_status=include_status, + ) + cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.folders) + cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.messages) + session.commit() + next_cursor, cursor_stable = _next_mailbox_cursor( + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=result.messages.folder, + limit=result.messages.limit, + offset=result.messages.offset, + total_count=result.messages.total_count, + uidvalidity=result.messages.uidvalidity, + messages=result.messages.messages, + ) + return MailMailboxBootstrapResponse( + profile_id=profile_id, + folder=result.messages.folder, + folders=_mailbox_folder_response(result.folders), + messages=_mailbox_messages_response( + profile_id=profile_id, + folder=result.messages.folder, + host=result.messages.host, + port=result.messages.port, + security=result.messages.security, + total_count=result.messages.total_count, + offset=result.messages.offset, + limit=result.messages.limit, + cursor=None, + next_cursor=next_cursor, + cursor_stable=cursor_stable, + full=not cursor_stable, + messages=result.messages.messages, + ), + ) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except ImapConfigurationError as exc: @@ -742,10 +1088,12 @@ def list_profile_mailbox_folders( @router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse) def list_profile_mailbox_messages( profile_id: str, + background_tasks: BackgroundTasks, folder: str = Query(default="INBOX", min_length=1, max_length=255), limit: int | None = Query(default=None, ge=1, le=100), offset: int = Query(default=0, ge=0, le=100000), cursor: str | None = None, + refresh: bool = False, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): @@ -755,6 +1103,55 @@ def list_profile_mailbox_messages( effective_limit = _mailbox_cursor_limit(cursor, limit) fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit) effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset) + if not refresh: + cached = cached_mailbox_message_page( + session, + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=folder, + limit=effective_limit, + offset=effective_offset, + ) + cache_matches_cursor = cached is not None and ( + cursor is None or (expected_uidvalidity is not None and cached.uidvalidity == expected_uidvalidity) + ) + if cached is not None and cache_matches_cursor: + refreshing = cached.stale and _schedule_mailbox_refresh( + background_tasks, + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=folder, + limit=effective_limit, + offset=effective_offset, + ) + next_cursor, cursor_stable = _next_mailbox_cursor( + tenant_id=principal.tenant_id, + profile_id=profile_id, + folder=cached.folder, + limit=cached.limit, + offset=cached.offset, + total_count=cached.total_count, + uidvalidity=cached.uidvalidity, + messages=cached.messages, + ) + return _mailbox_messages_response( + profile_id=profile_id, + folder=cached.folder, + host=imap.host, + port=imap.port, + security=imap.security.value, + total_count=cached.total_count, + offset=cached.offset, + limit=cached.limit, + cursor=cursor, + next_cursor=next_cursor, + cursor_stable=cursor_stable, + full=not cursor_stable, + messages=cached.messages, + from_cache=True, + refreshing=refreshing, + indexed_at=cached.indexed_at, + ) result = list_imap_messages( imap_config=imap, folder=folder, @@ -763,6 +1160,8 @@ def list_profile_mailbox_messages( after_uid=after_uid, expected_uidvalidity=expected_uidvalidity, ) + cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result) + session.commit() full = bool(cursor_values is not None and result.cursor_reset) next_cursor, cursor_stable = _next_mailbox_cursor( tenant_id=principal.tenant_id, @@ -774,7 +1173,7 @@ def list_profile_mailbox_messages( uidvalidity=result.uidvalidity, messages=result.messages, ) - return MailMailboxMessageListResponse( + return _mailbox_messages_response( profile_id=profile_id, folder=result.folder, host=result.host, @@ -787,7 +1186,7 @@ def list_profile_mailbox_messages( next_cursor=next_cursor, cursor_stable=cursor_stable, full=full or not cursor_stable, - messages=[_mailbox_summary_response(message) for message in result.messages], + messages=result.messages, ) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc diff --git a/src/govoplan_mail/backend/runtime.py b/src/govoplan_mail/backend/runtime.py index 295b1e5..33a6638 100644 --- a/src/govoplan_mail/backend/runtime.py +++ b/src/govoplan_mail/backend/runtime.py @@ -1,29 +1,10 @@ from __future__ import annotations -from typing import Any +from govoplan_core.core.runtime import ModuleRuntimeState -_runtime_settings: object | None = None +_runtime = ModuleRuntimeState("Mail") - -def configure_runtime(*, settings: object | None = None) -> None: - global _runtime_settings - if settings is not None: - _runtime_settings = settings - - -def get_settings() -> object: - if _runtime_settings is not None: - return _runtime_settings - try: - from govoplan_core.settings import settings as legacy_settings - except ModuleNotFoundError as exc: - raise RuntimeError("GovOPlaN Mail runtime settings are not configured") from exc - return legacy_settings - - -class SettingsProxy: - def __getattr__(self, name: str) -> Any: - return getattr(get_settings(), name) - - -settings = SettingsProxy() +configure_runtime = _runtime.configure_runtime +get_registry = _runtime.get_registry +get_settings = _runtime.get_settings +settings = _runtime.settings diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index bc9ee6c..d1183c8 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -6,7 +6,14 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator from govoplan_core.api.v1.schemas import DeltaDeletedItem -from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials +from govoplan_mail.backend.config import ( + ImapConfig, + ImapServerConfig, + SmtpConfig, + SmtpServerConfig, + TransportCredentials, + normalize_split_transport_credentials, +) class MailSmtpTestRequest(SmtpConfig): @@ -29,28 +36,7 @@ class MailServerProfileCredentialsPayload(BaseModel): 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 + return normalize_split_transport_credentials(value) def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]: @@ -213,6 +199,26 @@ class MailSettingsDeltaResponse(BaseModel): full: bool = False +class MailAddressLookupCandidate(BaseModel): + contact_id: str + address_book_id: str + display_name: str + email: str | None = None + email_label: str | None = None + organization: str | None = None + role_title: str | None = None + tags: list[str] = Field(default_factory=list) + source_kind: str = "local" + source_ref: str | None = None + source_revision: str | None = None + provenance: dict[str, Any] = Field(default_factory=dict) + + +class MailAddressLookupResponse(BaseModel): + available: bool = False + candidates: list[MailAddressLookupCandidate] = Field(default_factory=list) + + class MailConnectionTestResponse(BaseModel): ok: bool protocol: Literal["smtp", "imap"] @@ -239,6 +245,9 @@ class MailImapFolderListResponse(BaseModel): message: str folders: list[MailImapFolderResponse] = Field(default_factory=list) detected_sent_folder: str | None = None + from_cache: bool = False + refreshing: bool = False + indexed_at: datetime | None = None details: dict[str, Any] = Field(default_factory=dict) class MailMailboxAttachmentResponse(BaseModel): @@ -282,9 +291,19 @@ class MailMailboxMessageListResponse(BaseModel): next_cursor: str | None = None cursor_stable: bool = False full: bool = False + from_cache: bool = False + refreshing: bool = False + indexed_at: datetime | None = None messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) +class MailMailboxBootstrapResponse(BaseModel): + profile_id: str + folder: str + folders: MailImapFolderListResponse + messages: MailMailboxMessageListResponse + + class MailMailboxMessageResponse(BaseModel): profile_id: str folder: str diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py index 9246bf4..55e7a57 100644 --- a/src/govoplan_mail/backend/sending/imap.py +++ b/src/govoplan_mail/backend/sending/imap.py @@ -1,6 +1,7 @@ from __future__ import annotations import imaplib +import logging import re import socket import ssl @@ -21,6 +22,8 @@ from govoplan_mail.backend.dev.mock_mailbox import ( record_imap_append, ) +logger = logging.getLogger(__name__) + class ImapConfigurationError(ValueError): """Raised when IMAP settings are incomplete or inconsistent.""" @@ -70,6 +73,10 @@ class ImapMailboxAttachmentInfo: size_bytes: int +def _log_imap_cleanup_failure(action: str, exc: BaseException) -> None: + logger.debug("IMAP cleanup failed while %s: %s", action, exc, exc_info=True) + + @dataclass(frozen=True, slots=True) class ImapMailboxMessageSummary: uid: str @@ -119,6 +126,12 @@ class ImapMailboxMessageListResult: cursor_reset: bool = False +@dataclass(frozen=True, slots=True) +class ImapMailboxBootstrapResult: + folders: ImapFolderListResult + messages: ImapMailboxMessageListResult + + @dataclass(frozen=True, slots=True) class ImapMailboxMessageResult: host: str @@ -170,8 +183,8 @@ def _open_imap(config: ImapConfig) -> imaplib.IMAP4: except Exception: try: client.logout() # type: ignore[possibly-undefined] - except Exception: - pass + except Exception as cleanup_exc: + _log_imap_cleanup_failure("opening connection", cleanup_exc) raise @@ -306,58 +319,200 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult: finally: try: client.logout() - except Exception: - pass + except Exception as cleanup_exc: + _log_imap_cleanup_failure("testing login", cleanup_exc) -def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: +def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: + host, port = _require_imap_config(imap_config) + 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, + security=imap_config.security.value, + folders=folders, + detected_sent_folder="Sent", + ) + + +def _list_imap_folders_on_client( + client: imaplib.IMAP4, + *, + host: str, + port: int, + security: str, + include_status: bool, +) -> ImapFolderListResult: + typ, data = client.list() + if typ != "OK": + raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True) + + parsed: list[tuple[str, set[str]]] = [] + folders: list[ImapMailboxInfo] = [] + for item in data or []: + extracted = _extract_mailbox_name(item) + if not extracted: + continue + name, flags = extracted + parsed.append((name, flags)) + message_count, unseen_count = ( + (None, None) + if not include_status or _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, + port=port, + security=security, + folders=folders, + detected_sent_folder=_detect_sent_folder(parsed), + ) + + +def list_imap_folders(*, imap_config: ImapConfig, include_status: bool = True) -> ImapFolderListResult: """Return folders visible through IMAP LIST and the best sent-folder guess.""" host, port = _require_imap_config(imap_config) if is_mock_imap_host(imap_config.host): - 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, - security=imap_config.security.value, - folders=folders, - detected_sent_folder="Sent", - ) + return _mock_imap_folders(imap_config=imap_config) client = _open_imap(imap_config) try: - typ, data = client.list() - if typ != "OK": - raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True) - - parsed: list[tuple[str, set[str]]] = [] - folders: list[ImapMailboxInfo] = [] - for item in data or []: - extracted = _extract_mailbox_name(item) - if not extracted: - continue - name, flags = extracted - parsed.append((name, flags)) - message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name) - folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count)) - - return ImapFolderListResult( + return _list_imap_folders_on_client( + client, host=host, port=port, security=imap_config.security.value, - folders=folders, - detected_sent_folder=_detect_sent_folder(parsed), + include_status=include_status, ) finally: try: client.logout() - except Exception: - pass + except Exception as cleanup_exc: + _log_imap_cleanup_failure("listing folders", cleanup_exc) + + +def _preferred_mailbox_folder(folders: list[ImapMailboxInfo], requested: str | None, detected_sent_folder: str | None) -> str: + folder_names = {folder.name for folder in folders} + requested = (requested or "").strip() + if requested and requested in folder_names: + return requested + if "INBOX" in folder_names: + return "INBOX" + if detected_sent_folder and detected_sent_folder in folder_names: + return detected_sent_folder + return folders[0].name if folders else (requested or "INBOX") + + +def load_imap_mailbox_bootstrap( + *, + imap_config: ImapConfig, + folder: str = "INBOX", + limit: int = 50, + offset: int = 0, + include_folder_status: bool = False, +) -> ImapMailboxBootstrapResult: + """Load folders and one message page through a single IMAP connection.""" + + host, port = _require_imap_config(imap_config) + if is_mock_imap_host(imap_config.host): + folders = _mock_imap_folders(imap_config=imap_config) + selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder) + messages = list_imap_messages(imap_config=imap_config, folder=selected_folder, limit=limit, offset=offset) + return ImapMailboxBootstrapResult(folders=folders, messages=messages) + + client = _open_imap(imap_config) + try: + folders = _list_imap_folders_on_client( + client, + host=host, + port=port, + security=imap_config.security.value, + include_status=include_folder_status, + ) + selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder) + selected_folder, limit, offset = _normalize_mailbox_page(folder=selected_folder, limit=limit, offset=offset) + messages = _list_imap_messages_on_client( + client, + host=host, + port=port, + security=imap_config.security.value, + folder=selected_folder, + limit=limit, + offset=offset, + after_uid=None, + expected_uidvalidity=None, + ) + return ImapMailboxBootstrapResult(folders=folders, messages=messages) + finally: + try: + client.logout() + except Exception as cleanup_exc: + _log_imap_cleanup_failure("bootstrapping mailbox", cleanup_exc) + + +def _list_imap_messages_on_client( + client: imaplib.IMAP4, + *, + host: str, + port: int, + security: str, + folder: str, + limit: int, + offset: int, + after_uid: str | None, + expected_uidvalidity: str | None, +) -> ImapMailboxMessageListResult: + total_count, uidvalidity = _select_readonly(client, folder) + if after_uid is None and expected_uidvalidity is None: + 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=security, + folder=folder, + messages=messages, + total_count=total_count, + offset=offset, + limit=limit, + uidvalidity=None, + cursor_reset=False, + ) + uids = _search_message_uids(client) + cursor_reset = False + effective_after_uid = after_uid + effective_offset = offset + if expected_uidvalidity and expected_uidvalidity != uidvalidity: + effective_after_uid = None + effective_offset = 0 + cursor_reset = True + page_uids, effective_offset, anchor_missing = _paged_descending_uids( + uids, + offset=effective_offset, + limit=limit, + after_uid=effective_after_uid, + ) + messages = _fetch_message_summaries_by_uid(client, page_uids, folder) + return ImapMailboxMessageListResult( + host=host, + port=port, + security=security, + folder=folder, + messages=messages, + total_count=len(uids) if uids else total_count, + offset=effective_offset, + limit=limit, + uidvalidity=uidvalidity, + cursor_reset=cursor_reset or anchor_missing, + ) def _has_folder_flag(flags: set[str], flag: str) -> bool: @@ -691,9 +846,7 @@ def list_imap_messages( """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) + folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset) if is_mock_imap_host(imap_config.host): records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] cursor_reset = False @@ -736,6 +889,21 @@ def list_imap_messages( client = _open_imap(imap_config) try: total_count, uidvalidity = _select_readonly(client, folder) + if after_uid is None and expected_uidvalidity is None: + 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, + uidvalidity=None, + cursor_reset=False, + ) uids = _search_message_uids(client) cursor_reset = False effective_after_uid = after_uid @@ -766,8 +934,12 @@ def list_imap_messages( finally: try: client.logout() - except Exception: - pass + except Exception as cleanup_exc: + _log_imap_cleanup_failure("listing messages", cleanup_exc) + + +def _normalize_mailbox_page(*, folder: str | None, limit: int, offset: int) -> tuple[str, int, int]: + return (folder or "INBOX").strip() or "INBOX", max(1, min(limit, 100)), max(0, offset) def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]: @@ -809,8 +981,8 @@ def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapM finally: try: client.logout() - except Exception: - pass + except Exception as cleanup_exc: + _log_imap_cleanup_failure("reading message", cleanup_exc) def append_message_to_sent( @@ -867,5 +1039,5 @@ def append_message_to_sent( if client is not None: try: client.logout() - except Exception: - pass + except Exception as cleanup_exc: + _log_imap_cleanup_failure("appending sent message", cleanup_exc) diff --git a/src/govoplan_mail/backend/sending/rate_limit.py b/src/govoplan_mail/backend/sending/rate_limit.py index 06b3f85..2cd34c7 100644 --- a/src/govoplan_mail/backend/sending/rate_limit.py +++ b/src/govoplan_mail/backend/sending/rate_limit.py @@ -40,8 +40,8 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T if not enabled or not _distributed_rate_limit_enabled(): return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0) - redis_key = f"multimailer:ratelimit:{key}:next_allowed" - lock_key = f"multimailer:ratelimit:{key}:lock" + redis_key = f"govoplan:ratelimit:{key}:next_allowed" + lock_key = f"govoplan:ratelimit:{key}:lock" waited = 0.0 try: diff --git a/src/govoplan_mail/backend/sending/smtp.py b/src/govoplan_mail/backend/sending/smtp.py index 6325b31..0fb158e 100644 --- a/src/govoplan_mail/backend/sending/smtp.py +++ b/src/govoplan_mail/backend/sending/smtp.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import logging import smtplib import ssl from dataclasses import dataclass @@ -15,6 +16,8 @@ from govoplan_mail.backend.dev.mock_mailbox import ( record_smtp_delivery, ) +logger = logging.getLogger(__name__) + class SmtpConfigurationError(ValueError): """Raised when SMTP settings are incomplete or inconsistent.""" @@ -56,6 +59,10 @@ class SmtpSendResult: return len(self.envelope_recipients) - len(self.refused_recipients) +def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None: + logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True) + + def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]: if not config.host: raise SmtpConfigurationError("SMTP host is required") @@ -89,8 +96,8 @@ def _open_smtp(config: SmtpConfig) -> smtplib.SMTP: # on GC, but explicit cleanup is safer when the variable exists. try: smtp.quit() # type: ignore[possibly-undefined] - except Exception: - pass + except Exception as cleanup_exc: + _log_smtp_cleanup_failure("opening connection", cleanup_exc) raise @@ -134,11 +141,12 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult: finally: try: smtp.quit() - except Exception: + except Exception as quit_exc: + _log_smtp_cleanup_failure("testing login quit", quit_exc) try: smtp.close() - except Exception: - pass + except Exception as close_exc: + _log_smtp_cleanup_failure("testing login close", close_exc) def prepare_test_message( @@ -161,12 +169,12 @@ def prepare_test_message( del test_message[header] # Replace potential previous marker headers if the user test-sends an EML twice. - for header in ["X-MultiMailer-Test-Send"]: + for header in ["X-GovOPlaN-Test-Send"]: if header in test_message: del test_message[header] test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient)) - test_message["X-MultiMailer-Test-Send"] = "true" + test_message["X-GovOPlaN-Test-Send"] = "true" return test_message @@ -177,43 +185,97 @@ def _send_smtp_payload( envelope_from: str, envelope_recipients: list[str], ) -> SmtpSendResult: + host, port, recipients = _prepare_smtp_send( + smtp_config=smtp_config, + envelope_from=envelope_from, + envelope_recipients=envelope_recipients, + ) + + if is_mock_smtp_host(smtp_config.host): + _accepted, refused = _send_mock_smtp_payload( + message, + smtp_config=smtp_config, + envelope_from=envelope_from, + envelope_recipients=recipients, + ) + return _smtp_send_result( + smtp_config=smtp_config, + host=host, + port=port, + envelope_from=envelope_from, + envelope_recipients=recipients, + refused=refused, + ) + + refused = _send_network_smtp_payload( + message, + smtp_config=smtp_config, + envelope_from=envelope_from, + envelope_recipients=recipients, + ) + return _smtp_send_result( + smtp_config=smtp_config, + host=host, + port=port, + envelope_from=envelope_from, + envelope_recipients=recipients, + refused=refused, + ) + + +def _prepare_smtp_send( + *, + smtp_config: SmtpConfig, + envelope_from: str, + envelope_recipients: list[str], +) -> tuple[str, int, list[str]]: host, port = _require_smtp_config(smtp_config) if not envelope_from: raise SmtpConfigurationError("SMTP envelope sender is required") - if not envelope_recipients: + recipients = [recipient for recipient in envelope_recipients if recipient] + if not recipients: raise SmtpConfigurationError("at least one SMTP envelope recipient is required") + return host, port, recipients - if is_mock_smtp_host(smtp_config.host): - if consume_fail_next_smtp(): - raise SmtpSendError("Mock SMTP configured to fail the next send") - failures = get_failures() - reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower() - refused: dict[str, tuple[int, bytes]] = {} - accepted = list(envelope_recipients) - if reject_text: - refused = { - recipient: (550, b"mock recipient rejected") - for recipient in envelope_recipients - if reject_text in recipient.lower() - } - accepted = [recipient for recipient in envelope_recipients if recipient not in refused] - if not accepted: - raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}") - record_smtp_delivery( - message, - envelope_from=envelope_from, - envelope_recipients=accepted, - smtp_host=smtp_config.host, - ) - return SmtpSendResult( - host=host, - port=port, - security=smtp_config.security.value, - envelope_from=envelope_from, - envelope_recipients=list(envelope_recipients), - refused_recipients=_decode_refused(refused), - ) +def _send_mock_smtp_payload( + message: EmailMessage | bytes, + *, + smtp_config: SmtpConfig, + envelope_from: str, + envelope_recipients: list[str], +) -> tuple[list[str], dict[str, tuple[int, bytes]]]: + if consume_fail_next_smtp(): + raise SmtpSendError("Mock SMTP configured to fail the next send") + failures = get_failures() + reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower() + refused: dict[str, tuple[int, bytes]] = {} + accepted = list(envelope_recipients) + if reject_text: + refused = { + recipient: (550, b"mock recipient rejected") + for recipient in envelope_recipients + if reject_text in recipient.lower() + } + accepted = [recipient for recipient in envelope_recipients if recipient not in refused] + if not accepted: + raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}") + record_smtp_delivery( + message, + envelope_from=envelope_from, + envelope_recipients=accepted, + smtp_host=smtp_config.host, + ) + return accepted, refused + + +def _send_network_smtp_payload( + message: EmailMessage | bytes, + *, + smtp_config: SmtpConfig, + envelope_from: str, + envelope_recipients: list[str], +) -> dict[str, tuple[int, bytes]]: try: smtp = _open_smtp(smtp_config) except smtplib.SMTPAuthenticationError as exc: @@ -266,12 +328,24 @@ def _send_smtp_payload( finally: try: smtp.quit() - except Exception: + except Exception as quit_exc: + _log_smtp_cleanup_failure("sending message quit", quit_exc) try: smtp.close() - except Exception: - pass + except Exception as close_exc: + _log_smtp_cleanup_failure("sending message close", close_exc) + return refused + +def _smtp_send_result( + *, + smtp_config: SmtpConfig, + host: str, + port: int, + envelope_from: str, + envelope_recipients: list[str], + refused: dict[str, tuple[int, bytes]], +) -> SmtpSendResult: return SmtpSendResult( host=host, port=port, diff --git a/tests/test_imap_parser.py b/tests/test_imap_parser.py index 0606f9c..152fef5 100644 --- a/tests/test_imap_parser.py +++ b/tests/test_imap_parser.py @@ -5,6 +5,7 @@ import unittest from govoplan_mail.backend.sending.imap import ( _detect_sent_folder, _extract_mailbox_name, + _normalize_mailbox_page, _paged_descending_sequences, _parse_fetch_sequence, _sequence_set, @@ -63,6 +64,10 @@ class ImapMessagePaginationTests(unittest.TestCase): ) self.assertIsNone(_parse_fetch_sequence("UID 123 FLAGS ()")) + def test_normalizes_mailbox_pagination_request(self): + self.assertEqual(_normalize_mailbox_page(folder="", limit=0, offset=-5), ("INBOX", 1, 0)) + self.assertEqual(_normalize_mailbox_page(folder=" Sent ", limit=500, offset=3), ("Sent", 100, 3)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mail_profile_helpers.py b/tests/test_mail_profile_helpers.py new file mode 100644 index 0000000..b4182dd --- /dev/null +++ b/tests/test_mail_profile_helpers.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from govoplan_core.security.secrets import decrypt_secret, encrypt_secret +from govoplan_mail.backend.config import ImapConfig, SmtpConfig +from govoplan_mail.backend.mail_profiles import ( + EffectiveMailProfilePolicy, + _apply_profile_transport_update, + _merge_policy, + _next_profile_transport_state, + _policy_parent_lock_message, + _policy_parent_lock_violations, +) + + +class MailProfileTransportHelperTests(unittest.TestCase): + def test_next_transport_state_uses_saved_profile_credentials(self): + profile = SimpleNamespace( + tenant_id="tenant-1", + scope_type="tenant", + scope_id=None, + smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30}, + smtp_username="saved-smtp", + smtp_password_encrypted=encrypt_secret("smtp-secret"), + imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30}, + imap_username="saved-imap", + imap_password_encrypted=encrypt_secret("imap-secret"), + ) + + smtp, imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=False) + self.assertEqual(smtp.username, "saved-smtp") + self.assertEqual(smtp.password, "smtp-secret") + self.assertIsNotNone(imap) + self.assertEqual(imap.username, "saved-imap") + self.assertEqual(imap.password, "imap-secret") + + _, cleared_imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=True) + self.assertIsNone(cleared_imap) + + def test_apply_transport_update_preserves_unsupplied_passwords(self): + profile = SimpleNamespace( + smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30}, + smtp_username="saved-smtp", + smtp_password_encrypted=encrypt_secret("smtp-secret"), + imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30}, + imap_username="saved-imap", + imap_password_encrypted=encrypt_secret("imap-secret"), + ) + + _apply_profile_transport_update( + profile, + smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"), + imap=ImapConfig(host="imap2.example.org", username="new-imap"), + clear_imap=False, + ) + + self.assertEqual(profile.smtp_config["host"], "smtp2.example.org") + self.assertEqual(profile.smtp_username, "new-smtp") + self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "smtp-secret") + self.assertEqual(profile.imap_config["host"], "imap2.example.org") + self.assertEqual(profile.imap_username, "new-imap") + self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "imap-secret") + + _apply_profile_transport_update(profile, smtp=None, imap=None, clear_imap=True) + self.assertIsNone(profile.imap_config) + self.assertIsNone(profile.imap_username) + self.assertIsNone(profile.imap_password_encrypted) + + +class MailProfilePolicyHelperTests(unittest.TestCase): + def test_merge_policy_respects_locked_lower_level_limits(self): + policy = EffectiveMailProfilePolicy() + _merge_policy( + policy, + { + "blacklist": {"smtp_hosts": ["*.blocked.example"]}, + "allow_lower_level_limits": {"blacklist.smtp_hosts": False}, + }, + source="system", + ) + _merge_policy( + policy, + {"blacklist": {"smtp_hosts": ["*.tenant.example"]}}, + source="tenant", + source_id="tenant-1", + ) + + self.assertEqual(policy.blacklist_patterns["smtp_hosts"], ["*.blocked.example"]) + self.assertFalse(policy.allow_lower_level_limits["blacklist.smtp_hosts"]) + + def test_parent_lock_violations_are_reported_per_field(self): + violations = _policy_parent_lock_violations( + { + "allow_user_profiles": False, + "smtp_credentials.inherit": False, + "blacklist.smtp_hosts": False, + }, + { + "allow_user_profiles": True, + "smtp_credentials": {"inherit": False}, + "blacklist": {"smtp_hosts": ["*.blocked.example"]}, + "allow_lower_level_limits": {"allow_user_profiles": True}, + }, + ) + + self.assertEqual( + violations, + [ + "allow_user_profiles", + "blacklist.smtp_hosts", + "smtp_credentials.inherit", + "allow_lower_level_limits.allow_user_profiles", + ], + ) + self.assertEqual( + _policy_parent_lock_message("smtp_credentials.inherit"), + "SMTP credential inheritance is locked by an ancestor policy", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..9270340 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.modules import ModuleManifest +from govoplan_mail.backend.manifest import get_manifest + + +class MailManifestTests(unittest.TestCase): + def test_manifest_declares_optional_addresses_lookup(self) -> None: + manifest = get_manifest() + + self.assertIsInstance(manifest, ModuleManifest) + self.assertEqual(manifest.id, "mail") + self.assertIn("addresses", manifest.optional_dependencies) + self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_smtp_helpers.py b/tests/test_smtp_helpers.py new file mode 100644 index 0000000..2775a28 --- /dev/null +++ b/tests/test_smtp_helpers.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import unittest + +from govoplan_mail.backend.config import SmtpConfig +from govoplan_mail.backend.sending.smtp import ( + SmtpConfigurationError, + _prepare_smtp_send, + _smtp_send_result, +) + + +class SmtpSendHelperTests(unittest.TestCase): + def test_prepare_smtp_send_validates_envelope(self): + config = SmtpConfig(host="smtp.example.org", port=587, security="starttls") + with self.assertRaisesRegex(SmtpConfigurationError, "envelope sender"): + _prepare_smtp_send(smtp_config=config, envelope_from="", envelope_recipients=["user@example.org"]) + with self.assertRaisesRegex(SmtpConfigurationError, "recipient"): + _prepare_smtp_send(smtp_config=config, envelope_from="sender@example.org", envelope_recipients=[""]) + + def test_prepare_smtp_send_filters_blank_recipients(self): + config = SmtpConfig(host="smtp.example.org", port=587, security="starttls") + self.assertEqual( + _prepare_smtp_send( + smtp_config=config, + envelope_from="sender@example.org", + envelope_recipients=["", "user@example.org"], + ), + ("smtp.example.org", 587, ["user@example.org"]), + ) + + def test_smtp_send_result_decodes_refused_recipients(self): + config = SmtpConfig(host="smtp.example.org", port=587, security="starttls") + result = _smtp_send_result( + smtp_config=config, + host="smtp.example.org", + port=587, + envelope_from="sender@example.org", + envelope_recipients=["ok@example.org", "blocked@example.org"], + refused={"blocked@example.org": (550, b"blocked")}, + ) + self.assertEqual(result.accepted_count, 1) + self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked")) + + +if __name__ == "__main__": + unittest.main() + diff --git a/webui/package-lock.json b/webui/package-lock.json new file mode 100644 index 0000000..ea1a419 --- /dev/null +++ b/webui/package-lock.json @@ -0,0 +1,142 @@ +{ + "name": "@govoplan/mail-webui", + "version": "0.1.8", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@govoplan/mail-webui", + "version": "0.1.8", + "devDependencies": { + "typescript": "^5.7.2" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.8", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/webui/package.json b/webui/package.json index 2511dbb..98ce9f3 100644 --- a/webui/package.json +++ b/webui/package.json @@ -26,7 +26,7 @@ } }, "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" + "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-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index c1673ff..cd33fb9 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -1 +1 @@ -export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui"; +export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui"; diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index da0494a..68cbced 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -1,62 +1,66 @@ -import type { ApiSettings, DeltaDeletedItem } from "../types"; -import { apiFetch } from "./client"; +import type { + ApiSettings, + DeltaDeletedItem, + MailConnectionTestResponse, + MailImapFolderListResponse, + MailImapTestPayload, + MailProfilePolicy, + MailProfilePolicyResponse, + MailProfileScope, + MailSecurity, + MailServerProfile, + MailServerProfilePayload, + MailSmtpTestPayload, + MockMailboxMessage, + MockMailboxMessageResponse +} from "@govoplan/core-webui"; +import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client"; +export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui"; +export type { + MailConnectionTestResponse, + MailCredentialPolicy, + MailImapFolderListResponse, + MailImapFolderResponse, + MailImapTestPayload, + MailProfilePatternKey, + MailProfilePatternRules, + MailProfilePolicy, + MailProfilePolicyLimitKey, + MailProfilePolicyLimitPermissions, + MailProfilePolicyResponse, + MailProfileScope, + MailSecurity, + MailServerProfile, + MailServerProfileCredentialsPayload, + MailServerProfileListResponse, + MailServerProfilePayload, + MailSmtpTestPayload, + MailTransportCredentialsPayload, + MockMailboxMessage, + MockMailboxMessageResponse +} from "@govoplan/core-webui"; +export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui"; -export type MailSecurity = "plain" | "tls" | "starttls"; -export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign"; - -export type MailSmtpTestPayload = { - host?: string | null; - port?: number | null; - username?: string | null; - password?: string | null; - security?: MailSecurity; - timeout_seconds?: number; +export type MailAddressLookupCandidate = { + contact_id: string; + address_book_id: string; + display_name: string; + email?: string | null; + email_label?: string | null; + organization?: string | null; + role_title?: string | null; + tags: string[]; + source_kind: string; + source_ref?: string | null; + source_revision?: string | null; + provenance: Record; }; -export type MailImapTestPayload = MailSmtpTestPayload & { - sent_folder?: string | null; +export type MailAddressLookupResponse = { + available: boolean; + candidates: MailAddressLookupCandidate[]; }; -export type MailTransportCredentialsPayload = { - username?: string | null; - password?: string | null; -}; - -export type MailServerProfileCredentialsPayload = { - smtp?: MailTransportCredentialsPayload; - imap?: MailTransportCredentialsPayload; -}; - -export type MailConnectionTestResponse = { - ok: boolean; - protocol: "smtp" | "imap"; - host?: string | null; - port?: number | null; - security?: MailSecurity | string | null; - message: string; - details?: Record; -}; - -export type MailImapFolderResponse = { - name: string; - flags?: string[]; - message_count?: number | null; - unseen_count?: number | null; -}; - -export type MailImapFolderListResponse = { - ok: boolean; - protocol: "imap"; - host?: string | null; - port?: number | null; - security?: MailSecurity | string | null; - message: string; - folders: MailImapFolderResponse[]; - detected_sent_folder?: string | null; - details?: Record; -}; - - export type MailMailboxAttachment = { filename?: string | null; content_type: string; @@ -99,9 +103,19 @@ export type MailMailboxMessageListResponse = { next_cursor?: string | null; cursor_stable?: boolean; full?: boolean; + from_cache?: boolean; + refreshing?: boolean; + indexed_at?: string | null; messages: MailMailboxMessageSummary[]; }; +export type MailMailboxBootstrapResponse = { + profile_id: string; + folder: string; + folders: MailImapFolderListResponse; + messages: MailMailboxMessageListResponse; +}; + export type MailMailboxMessageResponse = { profile_id: string; folder: string; @@ -111,26 +125,6 @@ export type MailMailboxMessageResponse = { message: MailMailboxMessageDetail; }; -export type MailServerProfile = { - id: string; - tenant_id?: string | null; - scope_type: MailProfileScope; - scope_id?: string | null; - name: string; - slug: string; - description?: string | null; - is_active: boolean; - smtp: MailSmtpTestPayload; - imap?: MailImapTestPayload | null; - credentials?: MailServerProfileCredentialsPayload | null; - smtp_password_configured: boolean; - imap_password_configured: boolean; - created_at: string; - updated_at: string; -}; - -export type MailServerProfileListResponse = { profiles: MailServerProfile[] }; - export type MailSettingsDeltaResponse = { profiles: MailServerProfile[]; policy?: MailProfilePolicyResponse | null; @@ -141,92 +135,15 @@ export type MailSettingsDeltaResponse = { full: boolean; }; -export const mailProfilePatternKeys = [ - "smtp_hosts", - "imap_hosts", - "envelope_senders", - "from_headers", - "recipient_domains" -] as const; - -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; -}; - -export type MailProfilePolicy = { - allowed_profile_ids?: string[] | null; - allow_user_profiles?: boolean | null; - allow_group_profiles?: boolean | null; - allow_campaign_profiles?: boolean | null; - smtp_credentials?: MailCredentialPolicy | null; - imap_credentials?: MailCredentialPolicy | null; - whitelist?: MailProfilePatternRules | null; - blacklist?: MailProfilePatternRules | null; - allow_lower_level_limits?: MailProfilePolicyLimitPermissions | null; -}; - -export type PolicySourceStep = { - scope_type: string; - scope_id?: string | null; - label: string; - applied_fields?: string[]; - policy?: MailProfilePolicy | null; -}; - -export type MailProfilePolicyResponse = { - scope_type: MailProfileScope; - scope_id?: string | null; - policy: MailProfilePolicy; - effective_policy?: MailProfilePolicy | null; - parent_policy?: MailProfilePolicy | null; - effective_policy_sources?: PolicySourceStep[]; - parent_policy_sources?: PolicySourceStep[]; -}; - -export type MailServerProfilePayload = { - name: string; - slug?: string | null; - description?: string | null; - is_active?: boolean; - scope_type?: MailProfileScope; - scope_id?: string | null; - smtp: MailSmtpTestPayload; - imap?: MailImapTestPayload | null; - credentials?: MailServerProfileCredentialsPayload | null; -}; +export async function lookupMailAddresses(settings: ApiSettings, query: string, limit = 25): Promise { + return apiFetch(settings, apiPath("/api/v1/mail/address-lookup", { query, limit })); +} export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise { - const params = new URLSearchParams(); - if (includeInactive) params.set("include_inactive", "true"); - if (campaignId) params.set("campaign_id", campaignId); - const suffix = params.toString() ? `?${params.toString()}` : ""; - const response = await apiFetch(settings, `/api/v1/mail/profiles${suffix}`); - return response.profiles ?? []; + return apiGetList(settings, "/api/v1/mail/profiles", "profiles", { + include_inactive: includeInactive ? true : undefined, + campaign_id: campaignId + }); } export async function fetchMailSettingsDelta( @@ -240,15 +157,14 @@ export async function fetchMailSettingsDelta( limit?: number; } = {} ): Promise { - const search = new URLSearchParams(); - if (params.scope_type) search.set("scope_type", params.scope_type); - if (params.scope_id) search.set("scope_id", params.scope_id); - if (params.include_inactive) search.set("include_inactive", "true"); - if (params.campaign_id) search.set("campaign_id", params.campaign_id); - if (params.since) search.set("since", params.since); - if (params.limit) search.set("limit", String(params.limit)); - const suffix = search.toString() ? `?${search.toString()}` : ""; - return apiFetch(settings, `/api/v1/mail/settings/delta${suffix}`); + return apiFetch(settings, apiPath("/api/v1/mail/settings/delta", { + scope_type: params.scope_type, + scope_id: params.scope_id, + include_inactive: params.include_inactive ? true : undefined, + campaign_id: params.campaign_id, + since: params.since, + limit: params.limit + })); } export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise { @@ -277,11 +193,10 @@ export async function getMailProfilePolicy( scopeId?: string | null, campaignId?: string | null ): Promise { - const params = new URLSearchParams(); - if (scopeId) params.set("scope_id", scopeId); - if (campaignId) params.set("campaign_id", campaignId); - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`); + return apiFetch(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, { + scope_id: scopeId, + campaign_id: campaignId + })); } export async function updateMailProfilePolicy( @@ -290,29 +205,45 @@ export async function updateMailProfilePolicy( policy: MailProfilePolicy, scopeId?: string | null ): Promise { - const params = new URLSearchParams(); - if (scopeId) params.set("scope_id", scopeId); - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, { + return apiFetch(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, { scope_id: scopeId }), { method: "PUT", body: JSON.stringify({ policy }) }); } export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise { - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" }); + return apiPost(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`); } export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise { - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" }); + return apiPost(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`); } export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise { - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" }); + return apiPost(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`); } -export async function listMailboxFolders(settings: ApiSettings, profileId: string): Promise { - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`); +export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise { + return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, { + include_status: includeStatus ? true : undefined, + refresh: refresh ? true : undefined + })); +} + +export async function bootstrapMailbox( + settings: ApiSettings, + profileId: string, + folder = "INBOX", + limit = 50, + offset = 0, + refresh = false +): Promise { + return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, { + folder, + limit, + offset, + refresh: refresh ? true : undefined + })); } export async function listMailboxMessages( @@ -321,11 +252,16 @@ export async function listMailboxMessages( folder = "INBOX", limit = 50, offset = 0, - cursor?: string | null + cursor?: string | null, + refresh = false ): Promise { - const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) }); - if (cursor) params.set("cursor", cursor); - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`); + return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, { + folder, + limit, + offset, + cursor, + refresh: refresh ? true : undefined + })); } export async function getMailboxMessage( @@ -334,69 +270,34 @@ export async function getMailboxMessage( folder: string, uid: string ): Promise { - const params = new URLSearchParams({ folder }); - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`); + return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder })); } export async function testSmtpSettings( settings: ApiSettings, payload: MailSmtpTestPayload ): Promise { - return apiFetch(settings, "/api/v1/mail/test-smtp", { - method: "POST", - body: JSON.stringify(payload) - }); + return apiPostJson(settings, "/api/v1/mail/test-smtp", payload); } export async function testImapSettings( settings: ApiSettings, payload: MailImapTestPayload ): Promise { - return apiFetch(settings, "/api/v1/mail/test-imap", { - method: "POST", - body: JSON.stringify(payload) - }); + return apiPostJson(settings, "/api/v1/mail/test-imap", payload); } export async function listImapFolders( settings: ApiSettings, payload: MailImapTestPayload ): Promise { - return apiFetch(settings, "/api/v1/mail/list-imap-folders", { - method: "POST", - body: JSON.stringify(payload) - }); + return apiPostJson(settings, "/api/v1/mail/list-imap-folders", payload); } -export type MockMailboxMessage = { - id: string; - kind: "smtp" | "imap_append" | string; - created_at: string; - envelope_from?: string | null; - envelope_recipients?: string[]; - subject?: string | null; - from_header?: string | null; - to_header?: string | null; - cc_header?: string | null; - bcc_header?: string | null; - message_id?: string | null; - size_bytes?: number; - body_preview?: string | null; - attachment_count?: number; - folder?: string | null; - raw_eml?: string | null; - headers?: Record; - attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>; -}; - export type MockMailboxListResponse = { messages: MockMailboxMessage[]; }; -export type MockMailboxMessageResponse = { - message: MockMailboxMessage; -}; - export type MockMailboxFailureConfig = { fail_next_smtp?: boolean | null; fail_next_imap?: boolean | null; @@ -404,8 +305,7 @@ export type MockMailboxFailureConfig = { }; export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise { - const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; - return apiFetch(settings, `/api/v1/dev/mailbox/messages${suffix}`); + return apiFetch(settings, apiPath("/api/v1/dev/mailbox/messages", { kind })); } export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise { diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 518b560..82de6fb 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -37,6 +37,16 @@ import { ConfirmDialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { FormField, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { + mailProfileChildDescriptors, + mailProfileEditTargetInitialSection, + mailProfileEditTargetPanelMode, + mailProfileEditTargetShowsProfileFields, + mailProfileEditTargetShowsSettingsPanel, + mailProfileEditTargetVisibleSections, + type MailProfileEditTarget, + type MailProfileProtocol +} from "./mailProfileEditorModel"; export type MailProfileTargetOption = { id: string; label: string; @@ -96,7 +106,8 @@ type PolicyFlagValue = "inherit" | "allow" | "deny"; type CredentialInheritanceValue = "inherit" | "profile" | "local"; type MailProfileTreeRow = {kind: "profile";id: string;profile: MailServerProfile;} | -{kind: "credential";id: string;profile: MailServerProfile;protocol: "smtp" | "imap";}; +{kind: "server";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;} | +{kind: "credential";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;}; const securityOptions = mailServerSecurityOptions as readonly MailSecurity[]; @@ -135,6 +146,7 @@ export function MailProfileScopeManager({ const [profiles, setProfiles] = useState([]); const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || ""); const [editing, setEditing] = useState(null); + const [editingTarget, setEditingTarget] = useState({ kind: "create" }); const [deactivating, setDeactivating] = useState(null); const [draft, setDraft] = useState(emptyProfileDraft()); const [savedProfileDraftKey, setSavedProfileDraftKey] = useState(profileDraftKey(emptyProfileDraft())); @@ -233,15 +245,17 @@ export function MailProfileScopeManager({ const nextDraft = emptyProfileDraft(); setDraft(nextDraft); setSavedProfileDraftKey(profileDraftKey(nextDraft)); + setEditingTarget({ kind: "create" }); setEditing("new"); setError(""); setSuccess(""); } - function openEdit(profile: MailServerProfile) { + function openEdit(profile: MailServerProfile, target: MailProfileEditTarget = { kind: "profile" }) { const nextDraft = profileToDraft(profile); setDraft(nextDraft); setSavedProfileDraftKey(profileDraftKey(nextDraft)); + setEditingTarget(target); setEditing(profile); setError(""); setSuccess(""); @@ -252,6 +266,7 @@ export function MailProfileScopeManager({ const nextDraft = emptyProfileDraft(); setDraft(nextDraft); setSavedProfileDraftKey(profileDraftKey(nextDraft)); + setEditingTarget({ kind: "create" }); } async function saveProfile(): Promise { @@ -308,6 +323,8 @@ export function MailProfileScopeManager({ width: "minmax(260px, 1.4fr)", render: (row) => row.kind === "profile" ?
{row.profile.name}
{row.profile.slug} ยท {scopeLabel(row.profile)}
: + row.kind === "server" ? + : }, { @@ -322,7 +339,7 @@ export function MailProfileScopeManager({ id: "policy", header: "i18n:govoplan-mail.policy.bb9cf141", width: "minmax(150px, 0.8fr)", - render: (row) => row.kind === "profile" ? scopeLabel(row.profile) : mailCredentialPolicySummary(profileEffectivePolicy, row.protocol) + render: (row) => row.kind === "profile" ? scopeLabel(row.profile) : row.kind === "server" ? row.protocol.toUpperCase() : mailCredentialPolicySummary(profileEffectivePolicy, row.protocol) }, { id: "status", @@ -330,22 +347,33 @@ export function MailProfileScopeManager({ width: "110px", render: (row) => row.kind === "profile" ? {row.profile.is_active ? "i18n:govoplan-mail.active.a733b809" : "i18n:govoplan-mail.inactive.09af574c"} : + row.kind === "server" ? + {transportConfigured(row.profile, row.protocol) ? "i18n:govoplan-core.configured.668c5fff" : "i18n:govoplan-core.not_configured.811931bb"} : {mailCredentialConfigured(row.profile, row.protocol) ? "i18n:govoplan-mail.saved.c0ae8f6e" : "i18n:govoplan-mail.local.dc99d54d"} }], [profileEffectivePolicy]); function mailProfileChildren(row: MailProfileTreeRow): MailProfileTreeRow[] { if (row.kind !== "profile") return []; - const children: MailProfileTreeRow[] = [{ kind: "credential", id: `credential:${row.profile.id}:smtp`, profile: row.profile, protocol: "smtp" }]; - if (row.profile.imap) children.push({ kind: "credential", id: `credential:${row.profile.id}:imap`, profile: row.profile, protocol: "imap" }); + const children: MailProfileTreeRow[] = [ + ...mailProfileChildDescriptors(row.profile).map((child) => ({ ...child, profile: row.profile })) + ]; return children; } function renderMailProfileActions(row: MailProfileTreeRow) { + if (row.kind === "server") { + const label = `Edit ${row.protocol.toUpperCase()} server`; + return ( +
+ +
); + + } if (row.kind === "credential") { return (
- +
); } @@ -411,13 +439,13 @@ export function MailProfileScopeManager({ !busy && setEditing(null)} + title={profileDialogTitle(editing, editingTarget)} + onClose={() => !busy && closeProfileEditor()} className="admin-dialog admin-dialog-wide mail-profile-dialog" closeDisabled={busy} - footer={<>}> + footer={<>}> - + void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;}) { + effectivePolicy, + editTarget +}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;editTarget: MailProfileEditTarget;}) { const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [folderResult, setFolderResult] = useState(null); @@ -790,6 +810,11 @@ function ProfileForm({ const disabled = busy || !canWrite; const credentialDisabled = disabled || !canManageCredentials; const existingProfile = editing !== "new" ? editing : null; + const initialSection = mailProfileEditTargetInitialSection(editTarget); + const settingsPanelMode = mailProfileEditTargetPanelMode(editTarget); + const visibleSections = mailProfileEditTargetVisibleSections(editTarget); + const showProfileFields = mailProfileEditTargetShowsProfileFields(editTarget); + const showSettingsPanel = mailProfileEditTargetShowsSettingsPanel(editTarget); const draftHasImap = hasDraftImapSettings(draft); const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured); const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured); @@ -803,7 +828,7 @@ function ProfileForm({ setImapTestResult(null); setFolderResult(null); setMailActionState(null); - }, [editing]); + }, [editing, editTarget]); function patchSmtpSettings(patch: Partial) { setDraft({ @@ -896,12 +921,18 @@ function ProfileForm({ return (
+ {showProfileFields &&
setDraft({ ...draft, name: event.target.value })} /> setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" />