21 Commits

Author SHA1 Message Date
a86a779d5b Use shared settings target layout 2026-07-21 13:47:57 +02:00
26932d5ca8 refactor(webui): rely on central button layout 2026-07-21 13:32:10 +02:00
b5b5cc8d78 fix(webui): align Mail Core peer lock 2026-07-21 13:27:04 +02:00
9c0946f8d2 Remove the unused Mail data-grid shim 2026-07-21 13:24:31 +02:00
260e579701 refactor(webui): use central mailbox icon action 2026-07-21 13:24:06 +02:00
dea1e85bd3 Use the central mailbox work-surface CSS 2026-07-21 13:19:20 +02:00
e4594c355d Reuse shared IMAP message listing path 2026-07-21 12:52:05 +02:00
f3faa4dff9 Refactor mail profile lock validation 2026-07-21 12:52:01 +02:00
ff750ed8bd refactor(webui): use central mailbox pagination 2026-07-21 12:36:49 +02:00
e1f298f300 refactor(webui): use central profile selection 2026-07-21 12:34:39 +02:00
7cf5a69a85 refactor(webui): centralize mail profile controls 2026-07-21 12:23:51 +02:00
e67da95df6 Harden mail connector network boundaries 2026-07-21 12:10:32 +02:00
b2c013174a Log mailbox refresh failures safely 2026-07-21 03:16:23 +02:00
658c1e6d5d refactor(mail): focus profile editor on protocols 2026-07-20 20:06:42 +02:00
764577dc23 fix(mail): quote IMAP mailbox commands 2026-07-20 20:06:36 +02:00
2e906d9ddd fix(mail): rate-limit direct and fallback sends 2026-07-20 20:06:31 +02:00
79d3f45068 fix(mail): repair mailbox index schema drift 2026-07-20 20:06:26 +02:00
b0337b2d26 intermittent commit 2026-07-14 13:22:11 +02:00
4865de5007 Harden mail profile validation 2026-07-11 18:37:51 +02:00
5371cb3ad4 Release v0.1.8 2026-07-11 16:49:03 +02:00
286b6cafd9 Release v0.1.7 2026-07-11 02:34:57 +02:00
42 changed files with 3113 additions and 769 deletions

View File

@@ -32,8 +32,8 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
For combined checks, run: For combined checks, run:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan
./scripts/check-focused.sh tools/checks/check-focused.sh
``` ```
## Working Rules ## Working Rules

View File

@@ -1,5 +1,9 @@
# govoplan-mail # govoplan-mail
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package. GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
## Ownership ## Ownership

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

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

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.runtime import configure_runtime from govoplan_mail.backend.runtime import configure_runtime
from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.mail_profiles import (

View File

@@ -8,6 +8,7 @@ from govoplan_core.mail.config import (
StrictModel, StrictModel,
TransportCredentials, TransportCredentials,
TransportSecurity, TransportSecurity,
normalize_split_transport_credentials,
) )
__all__ = [ __all__ = [
@@ -18,4 +19,5 @@ __all__ = [
"StrictModel", "StrictModel",
"TransportCredentials", "TransportCredentials",
"TransportSecurity", "TransportSecurity",
"normalize_split_transport_credentials",
] ]

View File

@@ -3,7 +3,9 @@ from __future__ import annotations
import uuid import uuid
from typing import Any 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 sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin 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_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, 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) 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)

View File

@@ -285,11 +285,15 @@ def _legacy_tenant_settings(session: Session, tenant_id: str) -> dict[str, Any]
return _json_object(row[0]) if row else None return _json_object(row[0]) if row else None
def _legacy_subject_policy(session: Session, *, table_name: str, tenant_id: str, scope_id: str) -> dict[str, Any] | None: def _legacy_subject_policy(session: Session, *, subject_type: str, tenant_id: str, scope_id: str) -> dict[str, Any] | None:
if table_name not in {"access_users", "access_groups"}: if subject_type == "user":
statement = text("select mail_profile_policy from access_users where id = :scope_id and tenant_id = :tenant_id")
elif subject_type == "group":
statement = text("select mail_profile_policy from access_groups where id = :scope_id and tenant_id = :tenant_id")
else:
raise MailProfileError("Unsupported mail policy subject") raise MailProfileError("Unsupported mail policy subject")
row = session.execute( row = session.execute(
text(f"select mail_profile_policy from {table_name} where id = :scope_id and tenant_id = :tenant_id"), statement,
{"scope_id": scope_id, "tenant_id": tenant_id}, {"scope_id": scope_id, "tenant_id": tenant_id},
).first() ).first()
return _json_object(row[0]) if row else None return _json_object(row[0]) if row else None
@@ -304,7 +308,7 @@ def _user_exists(session: Session, *, tenant_id: str, user_id: str) -> bool:
if directory is not None: if directory is not None:
user = directory.get_user(user_id) user = directory.get_user(user_id)
return bool(user and user.tenant_id == tenant_id and user.status == "active") return bool(user and user.tenant_id == tenant_id and user.status == "active")
return _legacy_subject_policy(session, table_name="access_users", tenant_id=tenant_id, scope_id=user_id) is not None return _legacy_subject_policy(session, subject_type="user", tenant_id=tenant_id, scope_id=user_id) is not None
def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool: def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool:
@@ -312,7 +316,7 @@ def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool:
if directory is not None: if directory is not None:
group = directory.get_group(group_id) group = directory.get_group(group_id)
return bool(group and group.tenant_id == tenant_id and group.status == "active") return bool(group and group.tenant_id == tenant_id and group.status == "active")
return _legacy_subject_policy(session, table_name="access_groups", tenant_id=tenant_id, scope_id=group_id) is not None return _legacy_subject_policy(session, subject_type="group", tenant_id=tenant_id, scope_id=group_id) is not None
def _policy_scope_key(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str | None, str, str | None]: def _policy_scope_key(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str | None, str, str | None]:
@@ -356,10 +360,10 @@ def _legacy_mail_profile_policy(
if not scope_id: if not scope_id:
return None return None
if clean_scope == "user": if clean_scope == "user":
legacy = _legacy_subject_policy(session, table_name="access_users", tenant_id=tenant_id, scope_id=scope_id) legacy = _legacy_subject_policy(session, subject_type="user", tenant_id=tenant_id, scope_id=scope_id)
return _policy_from_attr(legacy) if legacy is not None else None return _policy_from_attr(legacy) if legacy is not None else None
if clean_scope == "group": if clean_scope == "group":
legacy = _legacy_subject_policy(session, table_name="access_groups", tenant_id=tenant_id, scope_id=scope_id) legacy = _legacy_subject_policy(session, subject_type="group", tenant_id=tenant_id, scope_id=scope_id)
return _policy_from_attr(legacy) if legacy is not None else None return _policy_from_attr(legacy) if legacy is not None else None
try: try:
context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
@@ -492,15 +496,7 @@ def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, s
normalized = normalize_mail_profile_policy(data) normalized = normalize_mail_profile_policy(data)
policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline)) policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline))
lower_limits = dict(policy.allow_lower_level_limits) lower_limits = dict(policy.allow_lower_level_limits)
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) _merge_profile_definition_policy(policy, normalized, lower_limits)
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_credential_policy( _merge_credential_policy(
policy.smtp_credentials, policy.smtp_credentials,
normalized.get("smtp_credentials") or {}, normalized.get("smtp_credentials") or {},
@@ -515,19 +511,43 @@ def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, s
lower_limits=lower_limits, lower_limits=lower_limits,
inherit_limit_key="imap_credentials.inherit", 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 {} whitelist = normalized.get("whitelist") or {}
blacklist = normalized.get("blacklist") or {} blacklist = normalized.get("blacklist") or {}
for key in PROFILE_PATTERN_KEYS: for key in PROFILE_PATTERN_KEYS:
allow_patterns = _meaningful_allow_patterns(whitelist.get(key, [])) allow_patterns = _meaningful_allow_patterns(whitelist.get(key, []))
if allow_patterns 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) policy.whitelist_groups.setdefault(key, []).append(allow_patterns)
deny_patterns = _clean_string_list(blacklist.get(key, [])) deny_patterns = _clean_string_list(blacklist.get(key, []))
if deny_patterns 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) 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 {} local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if local_lower_limits: if local_lower_limits:
policy.allow_lower_level_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 for key in MAIL_POLICY_LIMIT_KEYS
} }
@@ -1183,49 +1203,81 @@ def update_mail_server_profile(
if is_active is not None: if is_active is not None:
profile.is_active = is_active profile.is_active = is_active
next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "username": _profile_username(profile, "smtp"), "password": decrypt_secret(profile.smtp_password_encrypted)}) next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
if clear_imap: _assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap)
next_imap = None _apply_profile_transport_update(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
elif imap is not None: profile.updated_by_user_id = user_id
next_imap = imap session.add(profile)
elif profile.imap_config: session.flush()
next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "username": _profile_username(profile, "imap"), "password": decrypt_secret(profile.imap_password_encrypted)}) return profile
else:
next_imap = None
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_type = _profile_scope_type(profile)
scope_id = _profile_scope_id(profile) scope_id = _profile_scope_id(profile)
if scope_type == "campaign": 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": 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": 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": 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: if smtp is not None:
smtp_payload, smtp_username, smtp_password, username_supplied, password_supplied = _transport_payload(smtp) _apply_profile_transport_payload(profile, "smtp", 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)
if clear_imap: if clear_imap:
profile.imap_config = None profile.imap_config = None
profile.imap_username = None profile.imap_username = None
profile.imap_password_encrypted = None profile.imap_password_encrypted = None
elif imap is not None: elif imap is not None:
imap_payload, imap_username, imap_password, username_supplied, password_supplied = _transport_payload(imap) _apply_profile_transport_payload(profile, "imap", imap)
profile.imap_config = imap_payload
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: if username_supplied:
profile.imap_username = imap_username profile.smtp_username = username
if password_supplied: if password_supplied:
profile.imap_password_encrypted = encrypt_secret(imap_password) profile.smtp_password_encrypted = encrypt_secret(password)
profile.updated_by_user_id = user_id return
session.add(profile) profile.imap_config = payload
session.flush() if username_supplied:
return profile profile.imap_username = username
if password_supplied:
profile.imap_password_encrypted = encrypt_secret(password)
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None: def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
@@ -1342,29 +1394,99 @@ def get_mail_profile_policy(
def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None: def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None:
parent_limits = parent.allow_lower_level_limits violations = _policy_parent_lock_violations(parent.allow_lower_level_limits, normalized)
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) if violations:
if allowed_ids and not parent_limits.get("allowed_profile_ids", True): raise MailProfileError(_policy_parent_lock_message(violations[0]))
raise MailProfileError("Mail profile allow-list is locked by an ancestor policy")
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
if isinstance(normalized.get(key), bool) and not parent_limits.get(key, True): def _locked_boolean_policy_fields(
raise MailProfileError(f"{key} is locked by an ancestor policy") parent_limits: dict[str, bool],
normalized: dict[str, Any],
) -> list[str]:
return [
key
for key in (
"allow_user_profiles",
"allow_group_profiles",
"allow_campaign_profiles",
)
if isinstance(normalized.get(key), bool)
and not _policy_limit_enabled(parent_limits, key)
]
def _locked_pattern_policy_fields(
parent_limits: dict[str, bool],
normalized: dict[str, Any],
) -> list[str]:
violations: list[str] = []
for kind in ("whitelist", "blacklist"): for kind in ("whitelist", "blacklist"):
rules = normalized.get(kind) or {} rules = normalized.get(kind) or {}
if isinstance(rules, dict): if not isinstance(rules, dict):
for key in PROFILE_PATTERN_KEYS: continue
if _clean_string_list(rules.get(key, [])) and not parent_limits.get(f"{kind}.{key}", True): for key in PROFILE_PATTERN_KEYS:
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)
return violations
def _locked_credential_policy_fields(
parent_limits: dict[str, bool],
normalized: dict[str, Any],
) -> list[str]:
violations: list[str] = []
for protocol in ("smtp", "imap"): for protocol in ("smtp", "imap"):
local = normalized.get(f"{protocol}_credentials") or {} local = normalized.get(f"{protocol}_credentials") or {}
local_inherit = local.get("inherit") local_inherit = local.get("inherit")
if isinstance(local_inherit, bool) and not parent_limits.get(f"{protocol}_credentials.inherit", True): limit_key = f"{protocol}_credentials.inherit"
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by an ancestor policy") if isinstance(local_inherit, bool) and not _policy_limit_enabled(
parent_limits,
limit_key,
):
violations.append(limit_key)
return violations
def _locked_lower_limit_policy_fields(
parent_limits: dict[str, bool],
normalized: dict[str, Any],
) -> list[str]:
local_lower_limits = normalized.get("allow_lower_level_limits") or {} local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if isinstance(local_lower_limits, dict): if not isinstance(local_lower_limits, dict):
for key, allowed in local_lower_limits.items(): return []
if allowed is True and not parent_limits.get(key, True): return [
raise MailProfileError(f"{key} lower-level limiting cannot be re-enabled below a locked ancestor policy") f"allow_lower_level_limits.{key}"
for key, allowed in local_lower_limits.items()
if allowed is True and not _policy_limit_enabled(parent_limits, key)
]
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 _policy_limit_enabled(parent_limits, "allowed_profile_ids"):
violations.append("allowed_profile_ids")
violations.extend(_locked_boolean_policy_fields(parent_limits, normalized))
violations.extend(_locked_pattern_policy_fields(parent_limits, normalized))
violations.extend(_locked_credential_policy_fields(parent_limits, normalized))
violations.extend(_locked_lower_limit_policy_fields(parent_limits, normalized))
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( def set_mail_profile_policy(

View File

@@ -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

View File

@@ -72,7 +72,7 @@ ROLE_TEMPLATES = (
def _mail_router(context: ModuleContext): def _mail_router(context: ModuleContext):
from govoplan_mail.backend.runtime import configure_runtime 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 fastapi import APIRouter
from govoplan_mail.backend.router import router from govoplan_mail.backend.router import router
@@ -89,9 +89,9 @@ def _mail_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="mail", id="mail",
name="Mail", name="Mail",
version="0.1.6", version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns",), optional_dependencies=("campaigns", "addresses"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"), ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"),
), ),
@@ -102,6 +102,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name="addresses.lookup",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_mail_router, route_factory=_mail_router,
@@ -120,6 +126,8 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider( retirement_provider=drop_table_retirement_provider(
mail_models.MailServerProfile, mail_models.MailServerProfile,
mail_models.MailProfilePolicy, mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex,
label="Mail", label="Mail",
), ),
retirement_notes="Destructive retirement drops mail-owned database tables after the installer captures a database snapshot.", 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( persistent_table_uninstall_guard(
mail_models.MailServerProfile, mail_models.MailServerProfile,
mail_models.MailProfilePolicy, mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex,
label="Mail", label="Mail",
), ),
), ),

View File

@@ -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")

View File

@@ -0,0 +1,67 @@
"""repair mail mailbox index columns
Revision ID: 5f708192abcd
Revises: 4e5f708192ab
Create Date: 2026-07-14 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5f708192abcd"
down_revision = "4e5f708192ab"
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" in tables:
columns = _columns(inspector, "mail_mailbox_folder_index")
indexes = _indexes(inspector, "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))
if "ix_mail_mailbox_folder_index_message_indexed_at" not in indexes:
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" in tables:
columns = _columns(inspector, "mail_mailbox_message_index")
indexes = _indexes(inspector, "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"))
if op.get_bind().dialect.name != "sqlite":
op.alter_column("mail_mailbox_message_index", "sort_position", server_default=None)
if "ix_mail_mailbox_message_index_sort_position" not in indexes:
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
if "ix_mail_mailbox_message_index_page" not in indexes:
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "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:
indexes = _indexes(inspector, "mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_page" in indexes:
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_sort_position" in indexes:
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
if "sort_position" in _columns(inspector, "mail_mailbox_message_index"):
op.drop_column("mail_mailbox_message_index", "sort_position")
if "mail_mailbox_folder_index" in tables:
indexes = _indexes(inspector, "mail_mailbox_folder_index")
if "ix_mail_mailbox_folder_index_message_indexed_at" in indexes:
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
if "message_indexed_at" in _columns(inspector, "mail_mailbox_folder_index"):
op.drop_column("mail_mailbox_folder_index", "message_indexed_at")
def _columns(inspector, table_name: str) -> set[str]:
return {column["name"] for column in inspector.get_columns(table_name)}
def _indexes(inspector, table_name: str) -> set[str]:
return {index["name"] for index in inspector.get_indexes(table_name)}

View File

@@ -0,0 +1,39 @@
"""v0.1.7 mail baseline
Revision ID: 3d4e5f708192
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '3d4e5f708192'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('mail_profile_policies',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('policy', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_mail_profile_policies_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mail_profile_policies')),
sa.UniqueConstraint('tenant_id', 'scope_type', 'scope_id', name='uq_mail_profile_policies_scope')
)
op.create_index('ix_mail_profile_policies_scope', 'mail_profile_policies', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_mail_profile_policies_scope_id'), 'mail_profile_policies', ['scope_id'], unique=False)
op.create_index(op.f('ix_mail_profile_policies_scope_type'), 'mail_profile_policies', ['scope_type'], unique=False)
op.create_index(op.f('ix_mail_profile_policies_tenant_id'), 'mail_profile_policies', ['tenant_id'], unique=False)
def downgrade() -> None:
op.drop_table('mail_profile_policies')

View File

@@ -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")

View File

@@ -0,0 +1,67 @@
"""repair mail mailbox index columns
Revision ID: 5f708192abcd
Revises: 4e5f708192ab
Create Date: 2026-07-14 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5f708192abcd"
down_revision = "4e5f708192ab"
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" in tables:
columns = _columns(inspector, "mail_mailbox_folder_index")
indexes = _indexes(inspector, "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))
if "ix_mail_mailbox_folder_index_message_indexed_at" not in indexes:
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" in tables:
columns = _columns(inspector, "mail_mailbox_message_index")
indexes = _indexes(inspector, "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"))
if op.get_bind().dialect.name != "sqlite":
op.alter_column("mail_mailbox_message_index", "sort_position", server_default=None)
if "ix_mail_mailbox_message_index_sort_position" not in indexes:
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
if "ix_mail_mailbox_message_index_page" not in indexes:
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "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:
indexes = _indexes(inspector, "mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_page" in indexes:
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_sort_position" in indexes:
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
if "sort_position" in _columns(inspector, "mail_mailbox_message_index"):
op.drop_column("mail_mailbox_message_index", "sort_position")
if "mail_mailbox_folder_index" in tables:
indexes = _indexes(inspector, "mail_mailbox_folder_index")
if "ix_mail_mailbox_folder_index_message_indexed_at" in indexes:
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
if "message_indexed_at" in _columns(inspector, "mail_mailbox_folder_index"):
op.drop_column("mail_mailbox_folder_index", "message_indexed_at")
def _columns(inspector, table_name: str) -> set[str]:
return {column["name"] for column in inspector.get_columns(table_name)}
def _indexes(inspector, table_name: str) -> set[str]:
return {index["name"] for index in inspector.get_indexes(table_name)}

View File

@@ -1,13 +1,21 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status import dataclasses
import logging
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy import func, or_ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import ( from govoplan_mail.backend.schemas import (
MailAddressLookupCandidate,
MailAddressLookupResponse,
MailConnectionTestResponse, MailConnectionTestResponse,
MailImapFolderListResponse, MailImapFolderListResponse,
MailImapFolderResponse, MailImapFolderResponse,
MailMailboxBootstrapResponse,
MailImapTestRequest, MailImapTestRequest,
MailMailboxAttachmentResponse, MailMailboxAttachmentResponse,
MailMailboxMessageDetailResponse, MailMailboxMessageDetailResponse,
@@ -33,7 +41,15 @@ from govoplan_core.core.change_sequence import (
sequence_watermark_is_expired, sequence_watermark_is_expired,
) )
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint 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 ( from govoplan_mail.backend.mail_profiles import (
MailProfileError, MailProfileError,
create_mail_server_profile, create_mail_server_profile,
@@ -49,10 +65,12 @@ from govoplan_mail.backend.mail_profiles import (
update_mail_server_profile, update_mail_server_profile,
) )
from govoplan_mail.backend.config import ImapConfig, SmtpConfig 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 from govoplan_mail.backend.sending.smtp import test_smtp_login
router = APIRouter(prefix="/mail", tags=["mail"]) router = APIRouter(prefix="/mail", tags=["mail"])
logger = logging.getLogger(__name__)
MAIL_MODULE_ID = "mail" MAIL_MODULE_ID = "mail"
MAIL_PROFILES_COLLECTION = "mail.profiles" MAIL_PROFILES_COLLECTION = "mail.profiles"
@@ -62,6 +80,39 @@ MAIL_PROFILE_RESOURCE = "mail_profile"
MAIL_POLICY_RESOURCE = "mail_profile_policy" MAIL_POLICY_RESOURCE = "mail_profile_policy"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1" MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50 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: def _require_scope(principal: ApiPrincipal, scope: str) -> None:
@@ -338,6 +389,137 @@ 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.
logger.debug(
"Background mailbox index refresh failed (profile_id=%r, folder=%r)",
profile_id,
folder,
exc_info=True,
)
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): 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) profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
imap = imap_config_from_profile(profile) imap = imap_config_from_profile(profile)
@@ -378,6 +560,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) @router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
def mail_settings_delta( def mail_settings_delta(
scope_type: str = Query(default="tenant"), scope_type: str = Query(default="tenant"),
@@ -633,7 +833,7 @@ def write_mail_profile_policy(
scope_type = scope_type.strip().casefold() scope_type = scope_type.strip().casefold()
_require_policy_write_scope(principal, scope_type) _require_policy_write_scope(principal, scope_type)
try: try:
policy = set_mail_profile_policy( set_mail_profile_policy(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
scope_type=scope_type, scope_type=scope_type,
@@ -711,8 +911,7 @@ def list_profile_imap_folders(
if imap is None: if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration") raise MailProfileError("Mail-server profile has no IMAP configuration")
result = list_imap_folders(imap_config=imap) result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders] return _mailbox_folder_response(result)
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
except MailProfileError as exc: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@@ -722,15 +921,169 @@ def list_profile_imap_folders(
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse) @router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
def list_profile_mailbox_folders( def list_profile_mailbox_folders(
profile_id: str, profile_id: str,
background_tasks: BackgroundTasks,
include_status: bool = Query(default=False),
refresh: bool = False,
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_mailbox_read_scope(principal) _require_mailbox_read_scope(principal)
try: try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id) imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_folders(imap_config=imap) if not include_status and not refresh:
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders] cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
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 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: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc: except ImapConfigurationError as exc:
@@ -742,10 +1095,12 @@ def list_profile_mailbox_folders(
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse) @router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
def list_profile_mailbox_messages( def list_profile_mailbox_messages(
profile_id: str, profile_id: str,
background_tasks: BackgroundTasks,
folder: str = Query(default="INBOX", min_length=1, max_length=255), folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int | None = Query(default=None, ge=1, le=100), limit: int | None = Query(default=None, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000), offset: int = Query(default=0, ge=0, le=100000),
cursor: str | None = None, cursor: str | None = None,
refresh: bool = False,
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
@@ -755,6 +1110,55 @@ def list_profile_mailbox_messages(
effective_limit = _mailbox_cursor_limit(cursor, limit) 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) 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) 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( result = list_imap_messages(
imap_config=imap, imap_config=imap,
folder=folder, folder=folder,
@@ -763,6 +1167,8 @@ def list_profile_mailbox_messages(
after_uid=after_uid, after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity, 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) full = bool(cursor_values is not None and result.cursor_reset)
next_cursor, cursor_stable = _next_mailbox_cursor( next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
@@ -774,7 +1180,7 @@ def list_profile_mailbox_messages(
uidvalidity=result.uidvalidity, uidvalidity=result.uidvalidity,
messages=result.messages, messages=result.messages,
) )
return MailMailboxMessageListResponse( return _mailbox_messages_response(
profile_id=profile_id, profile_id=profile_id,
folder=result.folder, folder=result.folder,
host=result.host, host=result.host,
@@ -787,7 +1193,7 @@ def list_profile_mailbox_messages(
next_cursor=next_cursor, next_cursor=next_cursor,
cursor_stable=cursor_stable, cursor_stable=cursor_stable,
full=full or not 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: except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc

View File

@@ -1,29 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from govoplan_core.core.runtime import ModuleRuntimeState
_runtime_settings: object | None = None _runtime = ModuleRuntimeState("Mail")
configure_runtime = _runtime.configure_runtime
def configure_runtime(*, settings: object | None = None) -> None: get_registry = _runtime.get_registry
global _runtime_settings get_settings = _runtime.get_settings
if settings is not None: settings = _runtime.settings
_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()

View File

@@ -6,7 +6,14 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem 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): class MailSmtpTestRequest(SmtpConfig):
@@ -29,28 +36,7 @@ class MailServerProfileCredentialsPayload(BaseModel):
def _normalize_profile_transport_payload(value: object) -> object: def _normalize_profile_transport_payload(value: object) -> object:
if not isinstance(value, dict): return normalize_split_transport_credentials(value)
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]: def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]:
@@ -213,6 +199,26 @@ class MailSettingsDeltaResponse(BaseModel):
full: bool = False 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): class MailConnectionTestResponse(BaseModel):
ok: bool ok: bool
protocol: Literal["smtp", "imap"] protocol: Literal["smtp", "imap"]
@@ -239,6 +245,9 @@ class MailImapFolderListResponse(BaseModel):
message: str message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list) folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None 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) details: dict[str, Any] = Field(default_factory=dict)
class MailMailboxAttachmentResponse(BaseModel): class MailMailboxAttachmentResponse(BaseModel):
@@ -282,9 +291,19 @@ class MailMailboxMessageListResponse(BaseModel):
next_cursor: str | None = None next_cursor: str | None = None
cursor_stable: bool = False cursor_stable: bool = False
full: bool = False full: bool = False
from_cache: bool = False
refreshing: bool = False
indexed_at: datetime | None = None
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
class MailMailboxBootstrapResponse(BaseModel):
profile_id: str
folder: str
folders: MailImapFolderListResponse
messages: MailMailboxMessageListResponse
class MailMailboxMessageResponse(BaseModel): class MailMailboxMessageResponse(BaseModel):
profile_id: str profile_id: str
folder: str folder: str

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import imaplib import imaplib
import logging
import re import re
import socket import socket
import ssl import ssl
@@ -11,6 +12,13 @@ from email.message import EmailMessage
from email.parser import BytesParser from email.parser import BytesParser
from typing import Any from typing import Any
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
response_limit,
validate_outbound_host,
)
from govoplan_mail.backend.config import ImapConfig, TransportSecurity from govoplan_mail.backend.config import ImapConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import ( from govoplan_mail.backend.dev.mock_mailbox import (
MOCK_IMAP_FOLDERS, MOCK_IMAP_FOLDERS,
@@ -21,6 +29,33 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_imap_append, record_imap_append,
) )
logger = logging.getLogger(__name__)
class _OutboundPolicyIMAP4(imaplib.IMAP4):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="IMAP connector",
)
class _OutboundPolicyIMAP4SSL(imaplib.IMAP4_SSL):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="IMAP connector",
)
try:
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
except Exception:
sock.close()
raise
class ImapConfigurationError(ValueError): class ImapConfigurationError(ValueError):
"""Raised when IMAP settings are incomplete or inconsistent.""" """Raised when IMAP settings are incomplete or inconsistent."""
@@ -70,6 +105,10 @@ class ImapMailboxAttachmentInfo:
size_bytes: int 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) @dataclass(frozen=True, slots=True)
class ImapMailboxMessageSummary: class ImapMailboxMessageSummary:
uid: str uid: str
@@ -119,6 +158,12 @@ class ImapMailboxMessageListResult:
cursor_reset: bool = False cursor_reset: bool = False
@dataclass(frozen=True, slots=True)
class ImapMailboxBootstrapResult:
folders: ImapFolderListResult
messages: ImapMailboxMessageListResult
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ImapMailboxMessageResult: class ImapMailboxMessageResult:
host: str host: str
@@ -150,13 +195,22 @@ def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
def _open_imap(config: ImapConfig) -> imaplib.IMAP4: def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
host, port = _require_imap_config(config) host, port = _require_imap_config(config)
try:
validate_outbound_host(host, port=port, label="IMAP connector")
except OutboundHttpError as exc:
raise ImapConfigurationError(str(exc)) from exc
context = ssl.create_default_context() context = ssl.create_default_context()
try: try:
if config.security == TransportSecurity.TLS: if config.security == TransportSecurity.TLS:
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context) client: imaplib.IMAP4 = _OutboundPolicyIMAP4SSL(
host=host,
port=port,
timeout=config.timeout_seconds,
ssl_context=context,
)
else: else:
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds) client = _OutboundPolicyIMAP4(host=host, port=port, timeout=config.timeout_seconds)
if config.security == TransportSecurity.STARTTLS: if config.security == TransportSecurity.STARTTLS:
typ, data = client.starttls(ssl_context=context) typ, data = client.starttls(ssl_context=context)
if typ != "OK": if typ != "OK":
@@ -170,8 +224,8 @@ def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
except Exception: except Exception:
try: try:
client.logout() # type: ignore[possibly-undefined] client.logout() # type: ignore[possibly-undefined]
except Exception: except Exception as cleanup_exc:
pass _log_imap_cleanup_failure("opening connection", cleanup_exc)
raise raise
@@ -306,58 +360,200 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
finally: finally:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _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.""" """Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config) host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host): if is_mock_imap_host(imap_config.host):
records = list_records(limit=500) return _mock_imap_folders(imap_config=imap_config)
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",
)
client = _open_imap(imap_config) client = _open_imap(imap_config)
try: try:
typ, data = client.list() return _list_imap_folders_on_client(
if typ != "OK": client,
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(
host=host, host=host,
port=port, port=port,
security=imap_config.security.value, security=imap_config.security.value,
folders=folders, include_status=include_status,
detected_sent_folder=_detect_sent_folder(parsed),
) )
finally: finally:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _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: def _has_folder_flag(flags: set[str], flag: str) -> bool:
@@ -530,7 +726,7 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]: def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
typ, data = client.select(folder, readonly=True) typ, data = client.select(_quote_mailbox_name(folder), readonly=True)
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
selected_count = _decode_item(data[0] if data else None).strip() selected_count = _decode_item(data[0] if data else None).strip()
@@ -549,18 +745,22 @@ def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | Non
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]: def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.uid("fetch", str(uid), "(UID FLAGS RFC822.SIZE BODY.PEEK[])") max_bytes = response_limit("file")
typ, data = client.uid("fetch", str(uid), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True) raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data) fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
return fetched_uid or str(uid), flags, size_bytes, raw return fetched_uid or str(uid), flags, size_bytes, raw
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]: def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])") max_bytes = response_limit("file")
typ, data = client.fetch(str(sequence), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True) raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data) fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
if not fetched_uid: if not fetched_uid:
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True) raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
return fetched_uid, flags, size_bytes, raw return fetched_uid, flags, size_bytes, raw
@@ -579,12 +779,19 @@ def _sequence_set(sequences: list[str]) -> str:
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]: def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not sequences: if not sequences:
return [] return []
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])") max_bytes = response_limit("structured")
per_message_bytes = max(1, max_bytes // len(sequences)) + 1
typ, data = client.fetch(
_sequence_set(sequences),
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
)
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True) raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_sequence: dict[str, ImapMailboxMessageSummary] = {} by_sequence: dict[str, ImapMailboxMessageSummary] = {}
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data): parsed_parts = _parse_fetch_parts_with_sequence(data)
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
for sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
if not sequence or not fetched_uid: if not sequence or not fetched_uid:
continue continue
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes) by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
@@ -636,12 +843,20 @@ def _paged_descending_uids(
def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]: def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not uids: if not uids:
return [] return []
typ, data = client.uid("fetch", _sequence_set(uids), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])") max_bytes = response_limit("structured")
per_message_bytes = max(1, max_bytes // len(uids)) + 1
typ, data = client.uid(
"fetch",
_sequence_set(uids),
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
)
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True) raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_uid: dict[str, ImapMailboxMessageSummary] = {} by_uid: dict[str, ImapMailboxMessageSummary] = {}
for _sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data): parsed_parts = _parse_fetch_parts_with_sequence(data)
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
for _sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
if not fetched_uid: if not fetched_uid:
continue continue
by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes) by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
@@ -657,6 +872,27 @@ def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], fold
return summaries return summaries
def _ensure_imap_payload_within_limit(
payload: bytes,
*,
declared_size: int | None,
max_bytes: int,
label: str,
) -> None:
if (declared_size is not None and declared_size > max_bytes) or len(payload) > max_bytes:
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
def _ensure_imap_batch_within_limit(
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]],
*,
max_bytes: int,
label: str,
) -> None:
if sum(len(part[4]) for part in parts) > max_bytes:
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
def _mock_record_folder(record: dict[str, Any]) -> str: def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip() folder = str(record.get("folder") or "").strip()
if folder: if folder:
@@ -679,6 +915,71 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace") return "\n".join(lines).encode("utf-8", errors="replace")
def _mock_mailbox_page_offset(
records: list[dict[str, Any]],
*,
offset: int,
after_uid: str | None,
expected_uidvalidity: str | None,
) -> tuple[int, bool]:
if expected_uidvalidity and expected_uidvalidity != "mock-v1":
return 0, True
if after_uid:
record_ids = [str(record.get("id") or "") for record in records]
try:
return record_ids.index(str(after_uid)) + 1, False
except ValueError:
return 0, True
return min(offset, len(records)), False
def _mock_message_summary(record: dict[str, Any]) -> ImapMailboxMessageSummary:
return _message_summary_from_raw(
uid=str(record.get("id") or ""),
folder=_mock_record_folder(record),
raw=_mock_raw_bytes(record),
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None,
)
def _list_mock_imap_messages(
*,
host: str,
port: int,
security: str,
folder: str,
limit: int,
offset: int,
after_uid: str | None,
expected_uidvalidity: str | None,
) -> ImapMailboxMessageListResult:
records = [
record
for record in list_records(limit=500)
if _mock_folder_matches(record, folder)
]
effective_offset, cursor_reset = _mock_mailbox_page_offset(
records,
offset=offset,
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
page_records = records[effective_offset : effective_offset + limit]
return ImapMailboxMessageListResult(
host=host,
port=port,
security=security,
folder=folder,
messages=[_mock_message_summary(record) for record in page_records],
total_count=len(records),
offset=effective_offset,
limit=limit,
uidvalidity="mock-v1",
cursor_reset=cursor_reset,
)
def list_imap_messages( def list_imap_messages(
*, *,
imap_config: ImapConfig, imap_config: ImapConfig,
@@ -691,83 +992,41 @@ def list_imap_messages(
"""List mailbox messages without mutating read/unread state.""" """List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config) host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX" folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset)
limit = max(1, min(limit, 100))
offset = max(0, offset)
if is_mock_imap_host(imap_config.host): if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] return _list_mock_imap_messages(
cursor_reset = False
if expected_uidvalidity and expected_uidvalidity != "mock-v1":
effective_offset = 0
cursor_reset = True
elif after_uid:
record_ids = [str(record.get("id") or "") for record in records]
try:
effective_offset = record_ids.index(str(after_uid)) + 1
except ValueError:
effective_offset = 0
cursor_reset = True
else:
effective_offset = min(offset, len(records))
page_records = records[effective_offset:effective_offset + limit]
messages = [
_message_summary_from_raw(
uid=str(record.get("id") or ""),
folder=_mock_record_folder(record),
raw=_mock_raw_bytes(record),
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None,
)
for record in page_records
]
return ImapMailboxMessageListResult(
host=host, host=host,
port=port, port=port,
security=imap_config.security.value, security=imap_config.security.value,
folder=folder, folder=folder,
messages=messages,
total_count=len(records),
offset=effective_offset,
limit=limit, limit=limit,
uidvalidity="mock-v1", offset=offset,
cursor_reset=cursor_reset, after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
) )
client = _open_imap(imap_config) client = _open_imap(imap_config)
try: try:
total_count, uidvalidity = _select_readonly(client, folder) return _list_imap_messages_on_client(
uids = _search_message_uids(client) 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, host=host,
port=port, port=port,
security=imap_config.security.value, security=imap_config.security.value,
folder=folder, folder=folder,
messages=messages,
total_count=len(uids) if uids else total_count,
offset=effective_offset,
limit=limit, limit=limit,
uidvalidity=uidvalidity, offset=offset,
cursor_reset=cursor_reset or anchor_missing, after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
) )
finally: finally:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _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]: def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
@@ -809,8 +1068,8 @@ def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapM
finally: finally:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _log_imap_cleanup_failure("reading message", cleanup_exc)
def append_message_to_sent( def append_message_to_sent(
@@ -845,7 +1104,7 @@ def append_message_to_sent(
client = _open_imap(imap_config) client = _open_imap(imap_config)
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client) target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
internal_date = imaplib.Time2Internaldate(time.time()) internal_date = imaplib.Time2Internaldate(time.time())
typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes) typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes)
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False) raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
response = "; ".join(_decode_item(item) for item in (data or [])) or None response = "; ".join(_decode_item(item) for item in (data or [])) or None
@@ -867,5 +1126,5 @@ def append_message_to_sent(
if client is not None: if client is not None:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _log_imap_cleanup_failure("appending sent message", cleanup_exc)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import time import time
import threading
from dataclasses import dataclass from dataclasses import dataclass
from redis import Redis from redis import Redis
@@ -17,6 +18,10 @@ class RateLimitDecision:
waited_seconds: float waited_seconds: float
_local_rate_limit_lock = threading.Lock()
_local_next_allowed: dict[str, float] = {}
def _redis_client() -> Redis: def _redis_client() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True) return Redis.from_url(settings.redis_url, decode_responses=True)
@@ -30,18 +35,21 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
The implementation stores the next allowed send timestamp per key. A Redis The implementation stores the next allowed send timestamp per key. A Redis
lock keeps multiple Celery processes from reading/updating the timestamp at lock keeps multiple Celery processes from reading/updating the timestamp at
the same time. Direct local development runs do not have a broker, so Redis the same time. Direct local development runs do not have a broker, so they
is only used when Celery/worker mode is explicitly enabled. If Redis is use a process-local limiter. If Redis is unavailable in worker mode, the
unavailable, it falls back to no distributed wait. process-local fallback still protects single-process sends.
""" """
messages_per_minute = max(1, int(messages_per_minute or 1)) messages_per_minute = max(1, int(messages_per_minute or 1))
gap = 60.0 / messages_per_minute gap = 60.0 / messages_per_minute
if not enabled or not _distributed_rate_limit_enabled(): if not enabled:
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0) return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
if not _distributed_rate_limit_enabled():
waited = _wait_for_local_rate_limit(key, gap)
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
redis_key = f"multimailer:ratelimit:{key}:next_allowed" redis_key = f"govoplan:ratelimit:{key}:next_allowed"
lock_key = f"multimailer:ratelimit:{key}:lock" lock_key = f"govoplan:ratelimit:{key}:lock"
waited = 0.0 waited = 0.0
try: try:
@@ -56,7 +64,17 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
now = time.time() now = time.time()
client.set(redis_key, now + gap, ex=max(60, int(gap * 10))) client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
except (RedisError, TimeoutError, ValueError): except (RedisError, TimeoutError, ValueError):
# Development fallback: do not fail sending because Redis is absent. waited = _wait_for_local_rate_limit(key, gap)
waited = 0.0
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited) return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
def _wait_for_local_rate_limit(key: str, gap: float) -> float:
with _local_rate_limit_lock:
now = time.time()
next_allowed = _local_next_allowed.get(key, now)
waited = max(0.0, next_allowed - now)
_local_next_allowed[key] = max(now, next_allowed) + gap
if waited > 0:
time.sleep(waited)
return waited

View File

@@ -1,12 +1,19 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import logging
import smtplib import smtplib
import ssl import ssl
from dataclasses import dataclass from dataclasses import dataclass
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr from email.utils import formataddr
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
validate_outbound_host,
)
from govoplan_mail.backend.config import SmtpConfig, TransportSecurity from govoplan_mail.backend.config import SmtpConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import ( from govoplan_mail.backend.dev.mock_mailbox import (
consume_fail_next_smtp, consume_fail_next_smtp,
@@ -15,6 +22,35 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_smtp_delivery, record_smtp_delivery,
) )
logger = logging.getLogger(__name__)
class _OutboundPolicySMTP(smtplib.SMTP):
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
host,
port,
timeout=timeout,
source_address=self.source_address,
label="SMTP connector",
)
class _OutboundPolicySMTPSSL(smtplib.SMTP_SSL):
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
host,
port,
timeout=timeout,
source_address=self.source_address,
label="SMTP connector",
)
try:
return self.context.wrap_socket(sock, server_hostname=self._host)
except Exception:
sock.close()
raise
class SmtpConfigurationError(ValueError): class SmtpConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or inconsistent.""" """Raised when SMTP settings are incomplete or inconsistent."""
@@ -56,6 +92,10 @@ class SmtpSendResult:
return len(self.envelope_recipients) - len(self.refused_recipients) 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]: def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
if not config.host: if not config.host:
raise SmtpConfigurationError("SMTP host is required") raise SmtpConfigurationError("SMTP host is required")
@@ -68,14 +108,23 @@ def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP: def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
host, port = _require_smtp_config(config) host, port = _require_smtp_config(config)
try:
validate_outbound_host(host, port=port, label="SMTP connector")
except OutboundHttpError as exc:
raise SmtpConfigurationError(str(exc)) from exc
context = ssl.create_default_context() context = ssl.create_default_context()
try: try:
if config.security == TransportSecurity.TLS: if config.security == TransportSecurity.TLS:
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context) smtp: smtplib.SMTP = _OutboundPolicySMTPSSL(
host=host,
port=port,
timeout=config.timeout_seconds,
context=context,
)
smtp.ehlo() smtp.ehlo()
else: else:
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds) smtp = _OutboundPolicySMTP(host=host, port=port, timeout=config.timeout_seconds)
smtp.ehlo() smtp.ehlo()
if config.security == TransportSecurity.STARTTLS: if config.security == TransportSecurity.STARTTLS:
smtp.starttls(context=context) smtp.starttls(context=context)
@@ -89,8 +138,8 @@ def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
# on GC, but explicit cleanup is safer when the variable exists. # on GC, but explicit cleanup is safer when the variable exists.
try: try:
smtp.quit() # type: ignore[possibly-undefined] smtp.quit() # type: ignore[possibly-undefined]
except Exception: except Exception as cleanup_exc:
pass _log_smtp_cleanup_failure("opening connection", cleanup_exc)
raise raise
@@ -134,11 +183,12 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
finally: finally:
try: try:
smtp.quit() smtp.quit()
except Exception: except Exception as quit_exc:
_log_smtp_cleanup_failure("testing login quit", quit_exc)
try: try:
smtp.close() smtp.close()
except Exception: except Exception as close_exc:
pass _log_smtp_cleanup_failure("testing login close", close_exc)
def prepare_test_message( def prepare_test_message(
@@ -161,12 +211,12 @@ def prepare_test_message(
del test_message[header] del test_message[header]
# Replace potential previous marker headers if the user test-sends an EML twice. # 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: if header in test_message:
del test_message[header] del test_message[header]
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient)) 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 return test_message
@@ -177,43 +227,97 @@ def _send_smtp_payload(
envelope_from: str, envelope_from: str,
envelope_recipients: list[str], envelope_recipients: list[str],
) -> SmtpSendResult: ) -> 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) host, port = _require_smtp_config(smtp_config)
if not envelope_from: if not envelope_from:
raise SmtpConfigurationError("SMTP envelope sender is required") 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") 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: try:
smtp = _open_smtp(smtp_config) smtp = _open_smtp(smtp_config)
except smtplib.SMTPAuthenticationError as exc: except smtplib.SMTPAuthenticationError as exc:
@@ -266,12 +370,24 @@ def _send_smtp_payload(
finally: finally:
try: try:
smtp.quit() smtp.quit()
except Exception: except Exception as quit_exc:
_log_smtp_cleanup_failure("sending message quit", quit_exc)
try: try:
smtp.close() smtp.close()
except Exception: except Exception as close_exc:
pass _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( return SmtpSendResult(
host=host, host=host,
port=port, port=port,

View File

@@ -1,17 +1,53 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import patch
from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.sending.imap import ( from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapConfigurationError,
_detect_sent_folder, _detect_sent_folder,
_extract_mailbox_name, _extract_mailbox_name,
_fetch_message_by_uid,
_normalize_mailbox_page,
_open_imap,
_paged_descending_sequences, _paged_descending_sequences,
_parse_fetch_sequence, _parse_fetch_sequence,
_select_readonly,
_sequence_set, _sequence_set,
append_message_to_sent,
list_imap_messages,
) )
class ImapFolderParserTests(unittest.TestCase): class ImapFolderParserTests(unittest.TestCase):
def test_real_imap_connections_honor_deployment_egress_policy(self):
config = ImapConfig(host="imap.internal", port=993, security="tls")
with patch(
"govoplan_mail.backend.sending.imap.validate_outbound_host",
side_effect=OutboundHttpBlocked("private network blocked"),
), self.assertRaisesRegex(ImapConfigurationError, "private network blocked"):
_open_imap(config)
def test_imap_revalidates_and_pins_at_connection_time(self):
config = ImapConfig(host="imap.example.test", port=993, security="tls")
public = [(2, 1, 6, "", ("93.184.216.34", 993))]
private = [(2, 1, 6, "", ("127.0.0.1", 993))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
OutboundHttpBlocked,
"non-public network",
):
_open_imap(config)
socket_factory.assert_not_called()
def test_extracts_quoted_mailbox_after_quoted_delimiter(self): def test_extracts_quoted_mailbox_after_quoted_delimiter(self):
self.assertEqual( self.assertEqual(
_extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'), _extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'),
@@ -44,6 +80,108 @@ class ImapFolderParserTests(unittest.TestCase):
class ImapMessagePaginationTests(unittest.TestCase): class ImapMessagePaginationTests(unittest.TestCase):
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
records = [
{
"id": "3",
"kind": "smtp",
"raw_eml": "Subject: Three\r\n\r\nBody",
"size_bytes": 26,
},
{
"id": "2",
"kind": "smtp",
"raw_eml": "Subject: Two\r\n\r\nBody",
"size_bytes": 24,
},
]
config = ImapConfig(host="mock.imap.local")
with patch(
"govoplan_mail.backend.sending.imap.list_records",
return_value=records,
):
page = list_imap_messages(
imap_config=config,
after_uid="3",
expected_uidvalidity="mock-v1",
limit=1,
)
reset_page = list_imap_messages(
imap_config=config,
after_uid="3",
expected_uidvalidity="stale",
limit=1,
)
self.assertEqual([message.uid for message in page.messages], ["2"])
self.assertEqual(page.offset, 1)
self.assertFalse(page.cursor_reset)
self.assertEqual([message.uid for message in reset_page.messages], ["3"])
self.assertEqual(reset_page.offset, 0)
self.assertTrue(reset_page.cursor_reset)
def test_real_message_listing_delegates_to_shared_client_path(self):
class Client:
logged_out = False
def logout(self):
self.logged_out = True
client = Client()
expected = object()
config = ImapConfig(host="imap.example.org")
with (
patch(
"govoplan_mail.backend.sending.imap._open_imap",
return_value=client,
),
patch(
"govoplan_mail.backend.sending.imap._list_imap_messages_on_client",
return_value=expected,
) as list_on_client,
):
result = list_imap_messages(
imap_config=config,
folder=" Archive ",
limit=25,
offset=5,
after_uid="42",
expected_uidvalidity="7",
)
self.assertIs(result, expected)
self.assertTrue(client.logged_out)
list_on_client.assert_called_once_with(
client,
host="imap.example.org",
port=993,
security="tls",
folder="Archive",
limit=25,
offset=5,
after_uid="42",
expected_uidvalidity="7",
)
def test_full_message_fetch_uses_partial_range_and_deployment_limit(self):
class Client:
command = ""
def uid(self, command, uid, fetch_spec):
del command, uid
self.command = fetch_spec
return "OK", [(b"1 (UID 1 RFC822.SIZE 11)", b"12345678901")]
client = Client()
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "10"}), self.assertRaisesRegex(
ImapAppendError,
"deployment limit",
):
_fetch_message_by_uid(client, "1")
self.assertIn("BODY.PEEK[]<0.11>", client.command)
def test_paginates_sequence_numbers_newest_first(self): def test_paginates_sequence_numbers_newest_first(self):
self.assertEqual( self.assertEqual(
_paged_descending_sequences(120, offset=0, limit=5), _paged_descending_sequences(120, offset=0, limit=5),
@@ -63,6 +201,52 @@ class ImapMessagePaginationTests(unittest.TestCase):
) )
self.assertIsNone(_parse_fetch_sequence("UID 123 FLAGS ()")) 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))
class ImapMailboxCommandTests(unittest.TestCase):
def test_select_quotes_mailbox_name_with_spaces(self):
class Client:
untagged_responses = {"EXISTS": [b"0"], "UIDVALIDITY": [b"1"]}
def select(self, mailbox, readonly=False):
self.mailbox = mailbox
self.readonly = readonly
return "OK", [b"0"]
def response(self, code):
return "OK", [b"1"] if code == "UIDVALIDITY" else []
client = Client()
self.assertEqual(_select_readonly(client, "Gesendete Elemente"), (0, "1"))
self.assertEqual(client.mailbox, '"Gesendete Elemente"')
self.assertTrue(client.readonly)
def test_append_quotes_mailbox_name_with_spaces(self):
class Client:
def append(self, mailbox, flags, date_time, message):
self.mailbox = mailbox
self.flags = flags
self.date_time = date_time
self.message = message
return "OK", [b"APPEND completed"]
def logout(self):
return "BYE", [b"logged out"]
client = Client()
config = ImapConfig(host="imap.example.org", username="user", password="secret", sent_folder="auto")
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client):
result = append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config, folder="Gesendete Elemente")
self.assertEqual(client.mailbox, '"Gesendete Elemente"')
self.assertEqual(result.folder, "Gesendete Elemente")
self.assertEqual(result.bytes_appended, len(b"Subject: test\r\n\r\nBody"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -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()

20
tests/test_manifest.py Normal file
View File

@@ -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()

80
tests/test_rate_limit.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from redis.exceptions import RedisError
from govoplan_mail.backend.sending import rate_limit
class RateLimitTests(unittest.TestCase):
def setUp(self) -> None:
rate_limit._local_next_allowed.clear()
def test_local_fallback_waits_between_sends(self) -> None:
now = [100.0]
sleeps: list[float] = []
def fake_time() -> float:
return now[0]
def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
now[0] += seconds
with (
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=False),
patch.object(rate_limit.time, "time", fake_time),
patch.object(rate_limit.time, "sleep", fake_sleep),
):
first = rate_limit.wait_for_rate_limit(key="tenant:t:campaign:c", messages_per_minute=4)
second = rate_limit.wait_for_rate_limit(key="tenant:t:campaign:c", messages_per_minute=4)
self.assertEqual(first.waited_seconds, 0.0)
self.assertEqual(second.waited_seconds, 15.0)
self.assertEqual(sleeps, [15.0])
def test_disabled_rate_limit_does_not_reserve_local_slot(self) -> None:
now = [100.0]
sleeps: list[float] = []
with (
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=False),
patch.object(rate_limit.time, "time", lambda: now[0]),
patch.object(rate_limit.time, "sleep", lambda seconds: sleeps.append(seconds)),
):
disabled = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=4, enabled=False)
enabled = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=4)
self.assertEqual(disabled.waited_seconds, 0.0)
self.assertEqual(enabled.waited_seconds, 0.0)
self.assertEqual(sleeps, [])
def test_redis_failure_falls_back_to_local_wait(self) -> None:
now = [100.0]
sleeps: list[float] = []
def fail_redis_client():
raise RedisError("redis unavailable")
def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
now[0] += seconds
with (
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=True),
patch.object(rate_limit, "_redis_client", fail_redis_client),
patch.object(rate_limit.time, "time", lambda: now[0]),
patch.object(rate_limit.time, "sleep", fake_sleep),
):
first = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=60)
second = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=60)
self.assertEqual(first.waited_seconds, 0.0)
self.assertEqual(second.waited_seconds, 1.0)
self.assertEqual(sleeps, [1.0])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError,
_open_smtp,
_prepare_smtp_send,
_smtp_send_result,
)
class SmtpSendHelperTests(unittest.TestCase):
def test_real_smtp_connections_honor_deployment_egress_policy(self):
config = SmtpConfig(host="smtp.internal", port=587, security="starttls")
with patch(
"govoplan_mail.backend.sending.smtp.validate_outbound_host",
side_effect=OutboundHttpBlocked("private network blocked"),
), self.assertRaisesRegex(SmtpConfigurationError, "private network blocked"):
_open_smtp(config)
def test_smtp_revalidates_and_pins_at_connection_time(self):
config = SmtpConfig(host="smtp.example.test", port=587, security="starttls")
public = [(2, 1, 6, "", ("93.184.216.34", 587))]
private = [(2, 1, 6, "", ("127.0.0.1", 587))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
OutboundHttpBlocked,
"non-public network",
):
_open_smtp(config)
socket_factory.assert_not_called()
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()

142
webui/package-lock.json generated Normal file
View File

@@ -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.9",
"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"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css" "./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -26,7 +26,7 @@
} }
}, },
"scripts": { "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 && node scripts/test-mailbox-icon-button-structure.mjs"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"

View File

@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const webuiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const source = fs.readFileSync(path.join(webuiDir, "src/features/mail/MailboxPage.tsx"), "utf8");
const styles = fs.readFileSync(path.join(webuiDir, "src/styles/mail-profiles.css"), "utf8");
function assert(condition, message) {
if (!condition) throw new Error(message);
}
assert(source.includes("IconButton,"), "MailboxPage must import the central IconButton");
assert(
source.includes('<IconButton label="i18n:govoplan-mail.clear_message_search.cc9f2800"'),
"the clear-search action must use the central IconButton"
);
assert(
!source.includes('<button type="button" onClick={() => setMessageQuery("")}'),
"the raw clear-search button must not return"
);
assert(
!styles.includes(".mailbox-search-field button"),
"Mail must not redefine the central icon-button appearance"
);

View File

@@ -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";

View File

@@ -1,62 +1,66 @@
import type { ApiSettings, DeltaDeletedItem } from "../types"; import type {
import { apiFetch } from "./client"; 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 MailAddressLookupCandidate = {
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign"; contact_id: string;
address_book_id: string;
export type MailSmtpTestPayload = { display_name: string;
host?: string | null; email?: string | null;
port?: number | null; email_label?: string | null;
username?: string | null; organization?: string | null;
password?: string | null; role_title?: string | null;
security?: MailSecurity; tags: string[];
timeout_seconds?: number; source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
}; };
export type MailImapTestPayload = MailSmtpTestPayload & { export type MailAddressLookupResponse = {
sent_folder?: string | null; 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<string, unknown>;
};
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<string, unknown>;
};
export type MailMailboxAttachment = { export type MailMailboxAttachment = {
filename?: string | null; filename?: string | null;
content_type: string; content_type: string;
@@ -99,9 +103,19 @@ export type MailMailboxMessageListResponse = {
next_cursor?: string | null; next_cursor?: string | null;
cursor_stable?: boolean; cursor_stable?: boolean;
full?: boolean; full?: boolean;
from_cache?: boolean;
refreshing?: boolean;
indexed_at?: string | null;
messages: MailMailboxMessageSummary[]; messages: MailMailboxMessageSummary[];
}; };
export type MailMailboxBootstrapResponse = {
profile_id: string;
folder: string;
folders: MailImapFolderListResponse;
messages: MailMailboxMessageListResponse;
};
export type MailMailboxMessageResponse = { export type MailMailboxMessageResponse = {
profile_id: string; profile_id: string;
folder: string; folder: string;
@@ -111,26 +125,6 @@ export type MailMailboxMessageResponse = {
message: MailMailboxMessageDetail; 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 = { export type MailSettingsDeltaResponse = {
profiles: MailServerProfile[]; profiles: MailServerProfile[];
policy?: MailProfilePolicyResponse | null; policy?: MailProfilePolicyResponse | null;
@@ -141,92 +135,15 @@ export type MailSettingsDeltaResponse = {
full: boolean; full: boolean;
}; };
export const mailProfilePatternKeys = [ export async function lookupMailAddresses(settings: ApiSettings, query: string, limit = 25): Promise<MailAddressLookupResponse> {
"smtp_hosts", return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
"imap_hosts", }
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"allow_campaign_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
] as const;
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
};
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 listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> { export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams(); return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
if (includeInactive) params.set("include_inactive", "true"); include_inactive: includeInactive ? true : undefined,
if (campaignId) params.set("campaign_id", campaignId); campaign_id: campaignId
const suffix = params.toString() ? `?${params.toString()}` : ""; });
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
} }
export async function fetchMailSettingsDelta( export async function fetchMailSettingsDelta(
@@ -240,15 +157,14 @@ export async function fetchMailSettingsDelta(
limit?: number; limit?: number;
} = {} } = {}
): Promise<MailSettingsDeltaResponse> { ): Promise<MailSettingsDeltaResponse> {
const search = new URLSearchParams(); return apiFetch<MailSettingsDeltaResponse>(settings, apiPath("/api/v1/mail/settings/delta", {
if (params.scope_type) search.set("scope_type", params.scope_type); scope_type: params.scope_type,
if (params.scope_id) search.set("scope_id", params.scope_id); scope_id: params.scope_id,
if (params.include_inactive) search.set("include_inactive", "true"); include_inactive: params.include_inactive ? true : undefined,
if (params.campaign_id) search.set("campaign_id", params.campaign_id); campaign_id: params.campaign_id,
if (params.since) search.set("since", params.since); since: params.since,
if (params.limit) search.set("limit", String(params.limit)); limit: params.limit
const suffix = search.toString() ? `?${search.toString()}` : ""; }));
return apiFetch<MailSettingsDeltaResponse>(settings, `/api/v1/mail/settings/delta${suffix}`);
} }
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> { export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
@@ -277,11 +193,10 @@ export async function getMailProfilePolicy(
scopeId?: string | null, scopeId?: string | null,
campaignId?: string | null campaignId?: string | null
): Promise<MailProfilePolicyResponse> { ): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams(); return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
if (scopeId) params.set("scope_id", scopeId); scope_id: scopeId,
if (campaignId) params.set("campaign_id", campaignId); campaign_id: campaignId
const suffix = params.toString() ? `?${params.toString()}` : ""; }));
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
} }
export async function updateMailProfilePolicy( export async function updateMailProfilePolicy(
@@ -290,29 +205,45 @@ export async function updateMailProfilePolicy(
policy: MailProfilePolicy, policy: MailProfilePolicy,
scopeId?: string | null scopeId?: string | null
): Promise<MailProfilePolicyResponse> { ): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams(); return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, { scope_id: scopeId }), {
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ policy }) body: JSON.stringify({ policy })
}); });
} }
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" }); return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`);
} }
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" }); return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`);
} }
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> { export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" }); return apiPost<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`);
} }
export async function listMailboxFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> { export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`); return apiFetch<MailImapFolderListResponse>(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<MailMailboxBootstrapResponse> {
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
folder,
limit,
offset,
refresh: refresh ? true : undefined
}));
} }
export async function listMailboxMessages( export async function listMailboxMessages(
@@ -321,11 +252,16 @@ export async function listMailboxMessages(
folder = "INBOX", folder = "INBOX",
limit = 50, limit = 50,
offset = 0, offset = 0,
cursor?: string | null cursor?: string | null,
refresh = false
): Promise<MailMailboxMessageListResponse> { ): Promise<MailMailboxMessageListResponse> {
const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) }); return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
if (cursor) params.set("cursor", cursor); folder,
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`); limit,
offset,
cursor,
refresh: refresh ? true : undefined
}));
} }
export async function getMailboxMessage( export async function getMailboxMessage(
@@ -334,69 +270,34 @@ export async function getMailboxMessage(
folder: string, folder: string,
uid: string uid: string
): Promise<MailMailboxMessageResponse> { ): Promise<MailMailboxMessageResponse> {
const params = new URLSearchParams({ folder }); return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder }));
return apiFetch<MailMailboxMessageResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`);
} }
export async function testSmtpSettings( export async function testSmtpSettings(
settings: ApiSettings, settings: ApiSettings,
payload: MailSmtpTestPayload payload: MailSmtpTestPayload
): Promise<MailConnectionTestResponse> { ): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", { return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", payload);
method: "POST",
body: JSON.stringify(payload)
});
} }
export async function testImapSettings( export async function testImapSettings(
settings: ApiSettings, settings: ApiSettings,
payload: MailImapTestPayload payload: MailImapTestPayload
): Promise<MailConnectionTestResponse> { ): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", { return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", payload);
method: "POST",
body: JSON.stringify(payload)
});
} }
export async function listImapFolders( export async function listImapFolders(
settings: ApiSettings, settings: ApiSettings,
payload: MailImapTestPayload payload: MailImapTestPayload
): Promise<MailImapFolderListResponse> { ): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", { return apiPostJson<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", payload);
method: "POST",
body: JSON.stringify(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<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxListResponse = { export type MockMailboxListResponse = {
messages: MockMailboxMessage[]; messages: MockMailboxMessage[];
}; };
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export type MockMailboxFailureConfig = { export type MockMailboxFailureConfig = {
fail_next_smtp?: boolean | null; fail_next_smtp?: boolean | null;
fail_next_imap?: boolean | null; fail_next_imap?: boolean | null;
@@ -404,8 +305,7 @@ export type MockMailboxFailureConfig = {
}; };
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> { export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; return apiFetch<MockMailboxListResponse>(settings, apiPath("/api/v1/dev/mailbox/messages", { kind }));
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
} }
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> { export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {

View File

@@ -1,4 +0,0 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui"; import { AdminSelectionList, ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import { Pencil, Plus, Trash2 } from "lucide-react"; import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
@@ -7,8 +7,6 @@ import {
deactivateMailServerProfile, deactivateMailServerProfile,
fetchMailSettingsDelta, fetchMailSettingsDelta,
getMailProfilePolicy, getMailProfilePolicy,
listImapFolders,
listMailProfileImapFolders,
mailProfilePatternKeys, mailProfilePatternKeys,
mailProfilePolicyLimitKeys, mailProfilePolicyLimitKeys,
updateMailProfilePolicy, updateMailProfilePolicy,
@@ -37,6 +35,16 @@ import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { FormField, i18nMessage, useUnsavedDraftGuard } 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 = { export type MailProfileTargetOption = {
id: string; id: string;
label: string; label: string;
@@ -96,7 +104,8 @@ type PolicyFlagValue = "inherit" | "allow" | "deny";
type CredentialInheritanceValue = "inherit" | "profile" | "local"; type CredentialInheritanceValue = "inherit" | "profile" | "local";
type MailProfileTreeRow = type MailProfileTreeRow =
{kind: "profile";id: string;profile: MailServerProfile;} | {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[]; const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
@@ -135,6 +144,7 @@ export function MailProfileScopeManager({
const [profiles, setProfiles] = useState<MailServerProfile[]>([]); const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || ""); const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
const [editing, setEditing] = useState<EditingProfile>(null); const [editing, setEditing] = useState<EditingProfile>(null);
const [editingTarget, setEditingTarget] = useState<MailProfileEditTarget>({ kind: "create" });
const [deactivating, setDeactivating] = useState<MailServerProfile | null>(null); const [deactivating, setDeactivating] = useState<MailServerProfile | null>(null);
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft()); const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft());
const [savedProfileDraftKey, setSavedProfileDraftKey] = useState(profileDraftKey(emptyProfileDraft())); const [savedProfileDraftKey, setSavedProfileDraftKey] = useState(profileDraftKey(emptyProfileDraft()));
@@ -233,15 +243,17 @@ export function MailProfileScopeManager({
const nextDraft = emptyProfileDraft(); const nextDraft = emptyProfileDraft();
setDraft(nextDraft); setDraft(nextDraft);
setSavedProfileDraftKey(profileDraftKey(nextDraft)); setSavedProfileDraftKey(profileDraftKey(nextDraft));
setEditingTarget({ kind: "create" });
setEditing("new"); setEditing("new");
setError(""); setError("");
setSuccess(""); setSuccess("");
} }
function openEdit(profile: MailServerProfile) { function openEdit(profile: MailServerProfile, target: MailProfileEditTarget = { kind: "profile" }) {
const nextDraft = profileToDraft(profile); const nextDraft = profileToDraft(profile);
setDraft(nextDraft); setDraft(nextDraft);
setSavedProfileDraftKey(profileDraftKey(nextDraft)); setSavedProfileDraftKey(profileDraftKey(nextDraft));
setEditingTarget(target);
setEditing(profile); setEditing(profile);
setError(""); setError("");
setSuccess(""); setSuccess("");
@@ -252,6 +264,7 @@ export function MailProfileScopeManager({
const nextDraft = emptyProfileDraft(); const nextDraft = emptyProfileDraft();
setDraft(nextDraft); setDraft(nextDraft);
setSavedProfileDraftKey(profileDraftKey(nextDraft)); setSavedProfileDraftKey(profileDraftKey(nextDraft));
setEditingTarget({ kind: "create" });
} }
async function saveProfile(): Promise<boolean> { async function saveProfile(): Promise<boolean> {
@@ -308,6 +321,8 @@ export function MailProfileScopeManager({
width: "minmax(260px, 1.4fr)", width: "minmax(260px, 1.4fr)",
render: (row) => row.kind === "profile" ? render: (row) => row.kind === "profile" ?
<div className="connection-tree-main"><strong>{row.profile.name}</strong><div className="connection-tree-muted-line">{row.profile.slug} · {scopeLabel(row.profile)}</div></div> : <div className="connection-tree-main"><strong>{row.profile.name}</strong><div className="connection-tree-muted-line">{row.profile.slug} · {scopeLabel(row.profile)}</div></div> :
row.kind === "server" ?
<MailServerTreeCell profile={row.profile} protocol={row.protocol} /> :
<MailCredentialTreeCell profile={row.profile} protocol={row.protocol} /> <MailCredentialTreeCell profile={row.profile} protocol={row.protocol} />
}, },
{ {
@@ -322,38 +337,46 @@ export function MailProfileScopeManager({
id: "policy", id: "policy",
header: "i18n:govoplan-mail.policy.bb9cf141", header: "i18n:govoplan-mail.policy.bb9cf141",
width: "minmax(150px, 0.8fr)", 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", id: "status",
header: "i18n:govoplan-mail.status.bae7d5be", header: "i18n:govoplan-mail.status.bae7d5be",
width: "110px", width: "110px",
render: (row) => row.kind === "profile" ? render: (row) => row.kind === "profile" ?
<span className={`status-badge ${row.profile.is_active ? "success" : "neutral"}`}>{row.profile.is_active ? "i18n:govoplan-mail.active.a733b809" : "i18n:govoplan-mail.inactive.09af574c"}</span> : <StatusBadge status={row.profile.is_active ? "active" : "inactive"} label={row.profile.is_active ? "i18n:govoplan-mail.active.a733b809" : "i18n:govoplan-mail.inactive.09af574c"} /> :
<span className={`status-badge ${mailCredentialConfigured(row.profile, row.protocol) ? "success" : "neutral"}`}>{mailCredentialConfigured(row.profile, row.protocol) ? "i18n:govoplan-mail.saved.c0ae8f6e" : "i18n:govoplan-mail.local.dc99d54d"}</span> row.kind === "server" ?
<StatusBadge status={transportConfigured(row.profile, row.protocol) ? "success" : "inactive"} label={transportConfigured(row.profile, row.protocol) ? "i18n:govoplan-core.configured.668c5fff" : "i18n:govoplan-core.not_configured.811931bb"} /> :
<StatusBadge status={mailCredentialConfigured(row.profile, row.protocol) ? "success" : "inactive"} label={mailCredentialConfigured(row.profile, row.protocol) ? "i18n:govoplan-mail.saved.c0ae8f6e" : "i18n:govoplan-mail.local.dc99d54d"} />
}], }],
[profileEffectivePolicy]); [profileEffectivePolicy]);
function mailProfileChildren(row: MailProfileTreeRow): MailProfileTreeRow[] { function mailProfileChildren(row: MailProfileTreeRow): MailProfileTreeRow[] {
if (row.kind !== "profile") return []; if (row.kind !== "profile") return [];
const children: MailProfileTreeRow[] = [{ kind: "credential", id: `credential:${row.profile.id}:smtp`, profile: row.profile, protocol: "smtp" }]; const children: MailProfileTreeRow[] = [
if (row.profile.imap) children.push({ kind: "credential", id: `credential:${row.profile.id}:imap`, profile: row.profile, protocol: "imap" }); ...mailProfileChildDescriptors(row.profile).map((child) => ({ ...child, profile: row.profile }))
];
return children; return children;
} }
function renderMailProfileActions(row: MailProfileTreeRow) { function renderMailProfileActions(row: MailProfileTreeRow) {
if (row.kind === "credential") { if (row.kind === "server") {
return ( const label = `Edit ${row.protocol.toUpperCase()} server`;
<div className="admin-icon-actions"> return <TableActionGroup actions={[{
<Button type="button" className="admin-icon-button" title={i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() })} aria-label={i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() })} onClick={() => openEdit(row.profile)} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button> id: "edit-server", label, icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "server", protocol: row.protocol })
</div>); }]} />;
} }
return ( if (row.kind === "credential") {
<div className="admin-icon-actions"> return <TableActionGroup actions={[{
<Button type="button" className="admin-icon-button" title={i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name })} aria-label={i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name })} onClick={() => openEdit(row.profile)} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button> id: "edit-credentials", label: i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol })
<Button type="button" variant="danger" className="admin-icon-button" title={i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name })} aria-label={i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name })} onClick={() => setDeactivating(row.profile)} disabled={!canWriteProfiles || busy || !row.profile.is_active}><Trash2 size={16} /></Button> }]} />;
</div>);
}
return <TableActionGroup actions={[
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) },
{ id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy, onClick: () => setDeactivating(row.profile) }
]} />;
} }
@@ -361,7 +384,7 @@ export function MailProfileScopeManager({
<div className="mail-profile-manager"> <div className="mail-profile-manager">
{targetSelectionRequired && {targetSelectionRequired &&
<Card title={i18nMessage("i18n:govoplan-mail.value_scope", { value0: targetLabel })}> <Card title={i18nMessage("i18n:govoplan-mail.value_scope", { value0: targetLabel })}>
<div className="mail-profile-target-row"> <div className="settings-target-row">
<FormField label={targetLabel}> <FormField label={targetLabel}>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)} disabled={loading || busy || !hasSelectableTarget}> <select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)} disabled={loading || busy || !hasSelectableTarget}>
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>} {!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
@@ -379,7 +402,7 @@ export function MailProfileScopeManager({
actions={ actions={
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "i18n:govoplan-mail.loading.33ce4174" : "i18n:govoplan-mail.reload.cce71553"}</Button> <Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "i18n:govoplan-mail.loading.33ce4174" : "i18n:govoplan-mail.reload.cce71553"}</Button>
<Button variant="primary" onClick={openCreate} disabled={!canWriteProfiles || busy}><span className="button-icon-label"><Plus size={16} />i18n:govoplan-mail.new_profile.ca36da25</span></Button> <Button variant="primary" onClick={openCreate} disabled={!canWriteProfiles || busy}><Plus size={16} />i18n:govoplan-mail.new_profile.ca36da25</Button>
</div> </div>
}> }>
@@ -411,13 +434,13 @@ export function MailProfileScopeManager({
<Dialog <Dialog
open={editing !== null} open={editing !== null}
title={editing === "new" ? "i18n:govoplan-mail.create_mail_profile.4d2f8f9f" : "i18n:govoplan-mail.edit_mail_profile.95d1af9c"} title={profileDialogTitle(editing, editingTarget)}
onClose={() => !busy && setEditing(null)} onClose={() => !busy && closeProfileEditor()}
className="admin-dialog admin-dialog-wide mail-profile-dialog" className="admin-dialog admin-dialog-wide mail-profile-dialog"
closeDisabled={busy} closeDisabled={busy}
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}> footer={<><Button onClick={closeProfileEditor} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}>
<ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} effectivePolicy={profileEffectivePolicy} /> <ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} effectivePolicy={profileEffectivePolicy} editTarget={editingTarget} />
</Dialog> </Dialog>
<ConfirmDialog <ConfirmDialog
@@ -556,13 +579,6 @@ export function MailProfilePolicyEditor({
setPolicy((current) => normalizePolicy({ ...current, ...patch })); setPolicy((current) => normalizePolicy({ ...current, ...patch }));
} }
function toggleAllowedProfile(profileId: string, checked: boolean) {
const next = new Set(policy.allowed_profile_ids ?? []);
if (checked) next.add(profileId);else
next.delete(profileId);
patchPolicy({ allowed_profile_ids: [...next].sort() });
}
function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) { function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) {
patchPolicy({ [key]: flagToBoolean(value) }); patchPolicy({ [key]: flagToBoolean(value) });
} }
@@ -632,15 +648,17 @@ export function MailProfilePolicyEditor({
</div> </div>
</div> </div>
<p className="muted small-note">{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}</p> <p className="muted small-note">{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}</p>
<div className="mail-profile-checkbox-grid"> <AdminSelectionList
{candidateProfiles.map((profile) => options={candidateProfiles.map((profile) => ({
<label className="mail-profile-checkbox" key={profile.id}> id: profile.id,
<input type="checkbox" checked={selectedProfileIds.has(profile.id)} disabled={disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id))} onChange={(event) => toggleAllowedProfile(profile.id, event.target.checked)} /> label: profile.name,
<span><strong>{profile.name}</strong><small>{scopeLabel(profile)} · {transportLabel(profile.smtp)}</small></span> description: `${scopeLabel(profile)} · ${transportLabel(profile.smtp)}`,
</label> disabled: disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id))
)} }))}
{candidateProfiles.length === 0 && <p className="muted small-note">i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85</p>} selected={[...selectedProfileIds]}
</div> onChange={(allowedProfileIds) => patchPolicy({ allowed_profile_ids: [...allowedProfileIds].sort() })}
emptyText="i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85"
/>
{parentAllowedProfileIds && <p className="muted small-note">i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179 {parentAllowedProfileIds.size} i18n:govoplan-mail.profile_s.742e9200</p>} {parentAllowedProfileIds && <p className="muted small-note">i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179 {parentAllowedProfileIds.size} i18n:govoplan-mail.profile_s.742e9200</p>}
</section> </section>
@@ -771,25 +789,21 @@ function ProfileForm({
busy, busy,
canWrite, canWrite,
canManageCredentials, canManageCredentials,
effectivePolicy effectivePolicy,
editTarget
}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;editTarget: MailProfileEditTarget;}) {
}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;}) {
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null); const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null); const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null); const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const disabled = busy || !canWrite; const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials; const credentialDisabled = disabled || !canManageCredentials;
const existingProfile = editing !== "new" ? editing : null; 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 draftHasImap = hasDraftImapSettings(draft);
const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured); const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured);
const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured); const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured);
@@ -801,9 +815,8 @@ function ProfileForm({
useEffect(() => { useEffect(() => {
setSmtpTestResult(null); setSmtpTestResult(null);
setImapTestResult(null); setImapTestResult(null);
setFolderResult(null);
setMailActionState(null); setMailActionState(null);
}, [editing]); }, [editing, editTarget]);
function patchSmtpSettings(patch: Partial<MailServerSmtpSettings>) { function patchSmtpSettings(patch: Partial<MailServerSmtpSettings>) {
setDraft({ setDraft({
@@ -826,8 +839,6 @@ function ProfileForm({
}); });
} }
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) { function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
setDraft({ setDraft({
...draft, ...draft,
@@ -873,35 +884,20 @@ function ProfileForm({
} }
} }
async function runFolderLookup() {
if (!draftHasImap) return;
setMailActionState("folders");
setFolderResult(null);
try {
setFolderResult(useSavedImapTest && existingProfile ?
await listMailProfileImapFolders(settings, existingProfile.id) :
await listImapFolders(settings, rawImapPayload(draft, false)));
} catch (err) {
setFolderResult({ ok: false, message: errorMessage(err), folders: [] });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || disabled || !draftHasImap) return;
setDraft({ ...draft, imapSentFolder: folder });
}
return ( return (
<div className="mail-profile-form"> <div className="mail-profile-form">
{showProfileFields &&
<div className="admin-form-grid two-columns"> <div className="admin-form-grid two-columns">
<FormField label="i18n:govoplan-mail.name.709a2322"><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <FormField label="i18n:govoplan-mail.name.709a2322"><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField> <FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField> <FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
<FormField label="i18n:govoplan-mail.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="i18n:govoplan-mail.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div> </div>
}
{editTarget.kind === "profile" && existingProfile &&
<ProfileTransportSummary profile={existingProfile} />
}
{policyMessages.length > 0 && {policyMessages.length > 0 &&
<DismissibleAlert tone="warning" resetKey={policyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}> <DismissibleAlert tone="warning" resetKey={policyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
@@ -910,6 +906,7 @@ function ProfileForm({
</DismissibleAlert> </DismissibleAlert>
} }
{showSettingsPanel && settingsPanelMode &&
<MailServerSettingsPanel <MailServerSettingsPanel
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }} smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }} imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
@@ -931,12 +928,12 @@ function ProfileForm({
busyAction={mailActionState} busyAction={mailActionState}
onTestSmtp={() => void runSmtpTest()} onTestSmtp={() => void runSmtpTest()}
onTestImap={() => void runImapTest()} onTestImap={() => void runImapTest()}
onLookupFolders={() => void runFolderLookup()}
smtpTestResult={smtpTestResult} smtpTestResult={smtpTestResult}
imapTestResult={imapTestResult} imapTestResult={imapTestResult}
folderLookupResult={folderResult} initialSection={initialSection}
onUseDetectedFolder={useDetectedSentFolder} visibleSections={visibleSections}
useDetectedFolderDisabled={disabled || !draftHasImap} /> mode={settingsPanelMode} />
}
</div>); </div>);
@@ -992,6 +989,33 @@ function MailCredentialTreeCell({ profile, protocol }: {profile: MailServerProfi
} }
function ProfileTransportSummary({ profile }: {profile: MailServerProfile;}) {
return (
<div className="mail-profile-transport-summary">
<div>
<span>SMTP server</span>
<strong>{transportLabel(profile.smtp)}</strong>
<small>{mailCredentialConfigured(profile, "smtp") ? "i18n:govoplan-mail.password_saved.f6fab237" : "i18n:govoplan-mail.no_saved_password.32ce2b16"}</small>
</div>
<div>
<span>IMAP server</span>
<strong>{transportLabel(profile.imap)}</strong>
<small>{profile.imap ? mailCredentialConfigured(profile, "imap") ? "i18n:govoplan-mail.password_saved.f6fab237" : "i18n:govoplan-mail.no_saved_password.32ce2b16" : "i18n:govoplan-mail.not_configured.811931bb"}</small>
</div>
</div>);
}
function MailServerTreeCell({ profile, protocol }: {profile: MailServerProfile;protocol: "smtp" | "imap";}) {
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
return (
<div className="connection-tree-main">
<strong>{protocol.toUpperCase()} server</strong>
<div className="connection-tree-muted-line">{transportLabel(transport)}</div>
</div>);
}
function TransportCell({ profile, protocol }: {profile: MailServerProfile;protocol: "smtp" | "imap";}) { function TransportCell({ profile, protocol }: {profile: MailServerProfile;protocol: "smtp" | "imap";}) {
const transport = protocol === "smtp" ? profile.smtp : profile.imap; const transport = protocol === "smtp" ? profile.smtp : profile.imap;
if (!transport) return <span className="muted">i18n:govoplan-mail.not_configured.811931bb</span>; if (!transport) return <span className="muted">i18n:govoplan-mail.not_configured.811931bb</span>;
@@ -999,6 +1023,11 @@ function TransportCell({ profile, protocol }: {profile: MailServerProfile;protoc
return <div><strong>{transportLabel(transport)}</strong><div className="muted small-note">i18n:govoplan-mail.password.8be3c943 {hasPassword ? "configured" : "i18n:govoplan-mail.not_configured.67f2141f"}</div></div>; return <div><strong>{transportLabel(transport)}</strong><div className="muted small-note">i18n:govoplan-mail.password.8be3c943 {hasPassword ? "configured" : "i18n:govoplan-mail.not_configured.67f2141f"}</div></div>;
} }
function transportConfigured(profile: MailServerProfile, protocol: "smtp" | "imap"): boolean {
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
return Boolean(transport?.host);
}
function mailCredentialConfigured(profile: MailServerProfile, protocol: "smtp" | "imap"): boolean { function mailCredentialConfigured(profile: MailServerProfile, protocol: "smtp" | "imap"): boolean {
return protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured; return protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured;
} }
@@ -1007,6 +1036,13 @@ function mailCredentialPolicySummary(policy: MailProfilePolicy | null, protocol:
return credentialInheritanceLabel(protocol === "smtp" ? policy?.smtp_credentials : policy?.imap_credentials); return credentialInheritanceLabel(protocol === "smtp" ? policy?.smtp_credentials : policy?.imap_credentials);
} }
function profileDialogTitle(editing: EditingProfile, target: MailProfileEditTarget): string {
if (editing === "new") return "i18n:govoplan-mail.create_mail_profile.4d2f8f9f";
if (target.kind === "server") return `Edit ${target.protocol.toUpperCase()} server`;
if (target.kind === "credentials") return i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: target.protocol.toUpperCase() });
return "i18n:govoplan-mail.edit_mail_profile.95d1af9c";
}
function emptyProfileDraft(): ProfileDraft { function emptyProfileDraft(): ProfileDraft {
return { return {
name: "", name: "",
@@ -1112,7 +1148,7 @@ function rawImapPayload(draft: ProfileDraft, preserveBlankPassword: boolean): Ma
} }
function hasDraftImapSettings(draft: ProfileDraft): boolean { function hasDraftImapSettings(draft: ProfileDraft): boolean {
return hasMailImapSettings([draft.imapHost, draft.imapUsername, draft.imapPassword]); return hasMailImapSettings([draft.imapHost]);
} }
function normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy { function normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy {

View File

@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react"; import { ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { import {
Button, Button,
DataGridPaginationBar,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
IconButton,
LoadingIndicator, LoadingIndicator,
MessageDisplayPanel, MessageDisplayPanel,
formatDateTime, formatDateTime,
@@ -11,8 +13,8 @@ import {
type ApiSettings type ApiSettings
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
bootstrapMailbox,
getMailboxMessage, getMailboxMessage,
listMailboxFolders,
listMailboxMessages, listMailboxMessages,
listMailServerProfiles, listMailServerProfiles,
type MailImapFolderResponse, type MailImapFolderResponse,
@@ -50,6 +52,7 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
const messageListRequestRef = useRef(0); const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0); const messageDetailRequestRef = useRef(0);
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({}); const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
const skipNextMessageLoadRef = useRef(false);
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null; const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]); const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
@@ -70,8 +73,15 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]); useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]); useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
useEffect(() => {if (selectedProfileId) void loadFolders(selectedProfileId);}, [selectedProfileId]); useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
useEffect(() => {if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]); useEffect(() => {
if (!selectedProfileId || !selectedFolder || !foldersReady) return;
if (skipNextMessageLoadRef.current) {
skipNextMessageLoadRef.current = false;
return;
}
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => { useEffect(() => {
const handlePreviewShortcut = (event: KeyboardEvent) => { const handlePreviewShortcut = (event: KeyboardEvent) => {
@@ -130,6 +140,8 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
} }
} catch (err) { } catch (err) {
setError(errorText(err)); setError(errorText(err));
@@ -138,52 +150,70 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
} }
} }
async function loadFolders(profileId = selectedProfileId) { async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
if (!profileId) return; if (!profileId) return;
const requestId = ++folderRequestRef.current; const folderRequestId = ++folderRequestRef.current;
messageListRequestRef.current += 1; const messageRequestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1; messageDetailRequestRef.current += 1;
setLoadingFolders(true); setLoadingFolders(true);
setLoadingMessages(true);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessageTotalCount(null); setMessageTotalCount(null);
setMessagePage(1); setMessagePage(1);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey(""); setPendingMessageKey("");
setFolderError(""); setFolderError("");
setMessageError(""); setMessageError("");
setDetailError(""); setDetailError("");
setError(""); setError("");
try { try {
const response = await listMailboxFolders(settings, profileId); const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
if (requestId !== folderRequestRef.current) return; if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
if (!response.ok) throw new Error(response.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e"); if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }]; const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
setFolders(loaded); const loadedMessages = response.messages.messages ?? [];
setExpandedFolders(new Set()); const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = selectedFolder; let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
if (!nextFolder || !loaded.some((folder) => folder.name === nextFolder)) { if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
nextFolder = loaded.some((folder) => folder.name === "INBOX") ? "INBOX" : response.detected_sent_folder || loaded[0]?.name || "INBOX"; nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
} }
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
mailboxPageCursorsRef.current = {
[`${cursorKey}:1`]: null,
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
};
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== 1;
setFolders(foldersWithCounts);
setExpandedFolders(new Set());
setSelectedFolder(nextFolder); setSelectedFolder(nextFolder);
setFoldersLoadedForProfile(profileId); setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages);
setMessageTotalCount(total);
} catch (err) { } catch (err) {
if (requestId !== folderRequestRef.current) return; if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
setFolderError(errorText(err)); const message = errorText(err);
setError(errorText(err)); skipNextMessageLoadRef.current = false;
setFolderError(message);
setMessageError(message);
setError(message);
setFolders([]); setFolders([]);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === folderRequestRef.current) setLoadingFolders(false); if (folderRequestId === folderRequestRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current) setLoadingMessages(false);
} }
} }
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize) { async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
if (!profileId || !folder) return; if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current; const requestId = ++messageListRequestRef.current;
const offset = (Math.max(1, page) - 1) * pageSize; const offset = (Math.max(1, page) - 1) * pageSize;
@@ -194,12 +224,12 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
setDetailError(""); setDetailError("");
setError(""); setError("");
try { try {
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor); const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor, refresh);
if (requestId !== messageListRequestRef.current) return; if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? []; const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length; const total = response.total_count ?? loaded.length;
if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null; if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null;
if (response.next_cursor) mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor; mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
setMessages(loaded); setMessages(loaded);
setMessageTotalCount(total); setMessageTotalCount(total);
setFolderMessageCount(folder, total); setFolderMessageCount(folder, total);
@@ -271,6 +301,8 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
setLoadingFolders(false); setLoadingFolders(false);
setLoadingMessages(false); setLoadingMessages(false);
setLoadingMessage(false); setLoadingMessage(false);
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
} }
function openFolderNode(node: MailFolderNode) { function openFolderNode(node: MailFolderNode) {
@@ -365,11 +397,11 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.profiles.0c2a9300 i18n:govoplan-mail.profiles.0c2a9300
</Button> </Button>
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || loadingFolders} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963"> <Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={!selectedProfileId || loadingFolders || loadingMessages} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.folders.19adc47b i18n:govoplan-mail.folders.19adc47b
</Button> </Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c"> <Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.messages.f1702b46 i18n:govoplan-mail.messages.f1702b46
</Button> </Button>
@@ -385,7 +417,7 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
<label className="mailbox-search-field"> <label className="mailbox-search-field">
<Search size={15} aria-hidden="true" /> <Search size={15} aria-hidden="true" />
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="i18n:govoplan-mail.search_messages.abea65ae" /> <input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="i18n:govoplan-mail.search_messages.abea65ae" />
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="i18n:govoplan-mail.clear_message_search.cc9f2800"><X size={14} /></button>} {messageQuery && <IconButton label="i18n:govoplan-mail.clear_message_search.cc9f2800" icon={<X />} variant="ghost" onClick={() => setMessageQuery("")} />}
</label> </label>
</div> </div>
@@ -444,10 +476,12 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
})} })}
</div> </div>
</div> </div>
<MailboxPagination <DataGridPaginationBar
page={messagePage} page={messagePage}
pageSize={messagePageSize} pageSize={messagePageSize}
totalRows={messageTotalCount ?? 0} totalRows={messageTotalCount ?? 0}
className="mailbox-pagination"
ariaLabel="i18n:govoplan-mail.mailbox_message_pagination.965407bf"
disabled={loadingMessages || !foldersReady} disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage} onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize} /> onPageSizeChange={changeMessagePageSize} />
@@ -491,31 +525,6 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
} }
function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: {page: number;pageSize: number;totalRows: number;disabled: boolean;onPageChange: (page: number) => void;onPageSizeChange: (pageSize: number) => void;}) {
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(totalRows, page * pageSize);
const options = [10, 25, 50, 100];
return (
<div className="data-grid-pagination mailbox-pagination" aria-label="i18n:govoplan-mail.mailbox_message_pagination.965407bf">
<div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div>
<label className="data-grid-page-size">
<span>i18n:govoplan-mail.rows_per_page.af2f9c1b</span>
<select value={pageSize} disabled={disabled} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
{options.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<div className="data-grid-page-controls">
<button type="button" aria-label="i18n:govoplan-mail.first_page.49d74b49" disabled={disabled || page <= 1} onClick={() => onPageChange(1)}><ChevronsLeft size={16} /></button>
<button type="button" aria-label="i18n:govoplan-mail.previous_page.81f54719" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
<span>i18n:govoplan-mail.page.fb06270f {page} of {pageCount}</span>
<button type="button" aria-label="i18n:govoplan-mail.next_page.4bfc194b" disabled={disabled || page >= pageCount} onClick={() => onPageChange(page + 1)}><ChevronRight size={16} /></button>
<button type="button" aria-label="i18n:govoplan-mail.last_page.b01f16ae" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button>
</div>
</div>);
}
function mailboxMessageKey(folder: string, uid: string): string { function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`; return `${folder || "INBOX"}::${uid}`;
} }

View File

@@ -88,10 +88,39 @@ export function wildcardPatternMatches(pattern: string, rawValue: string): boole
const normalizedPattern = pattern.trim(); const normalizedPattern = pattern.trim();
const value = rawValue.trim(); const value = rawValue.trim();
if (!normalizedPattern || !value) return false; if (!normalizedPattern || !value) return false;
if (normalizedPattern === "*") return true; return wildcardMatch(normalizedPattern.toLowerCase(), value.toLowerCase());
const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); }
const regex = `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`;
return new RegExp(regex, "i").test(value); function wildcardMatch(pattern: string, value: string): boolean {
let patternIndex = 0;
let valueIndex = 0;
let starIndex = -1;
let valueRetryIndex = 0;
while (valueIndex < value.length) {
const token = pattern[patternIndex];
if (token === "?" || token === value[valueIndex]) {
patternIndex += 1;
valueIndex += 1;
continue;
}
if (token === "*") {
starIndex = patternIndex;
valueRetryIndex = valueIndex;
patternIndex += 1;
continue;
}
if (starIndex !== -1) {
patternIndex = starIndex + 1;
valueRetryIndex += 1;
valueIndex = valueRetryIndex;
continue;
}
return false;
}
while (pattern[patternIndex] === "*") patternIndex += 1;
return patternIndex === pattern.length;
} }
function patternsFor( function patternsFor(

View File

@@ -0,0 +1,60 @@
export type MailProfileProtocol = "smtp" | "imap";
export type MailProfileEditSection = MailProfileProtocol;
export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileEditTarget =
| {kind: "create";}
| {kind: "profile";}
| {kind: "server";protocol: MailProfileProtocol;}
| {kind: "credentials";protocol: MailProfileProtocol;};
export type MailProfileTransportLike = {
host?: string | null;
};
export type MailProfileTreeProfileLike = {
id: string;
imap?: MailProfileTransportLike | null;
};
export type MailProfileChildDescriptor = {
kind: "server" | "credential";
id: string;
protocol: MailProfileProtocol;
};
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
const children: MailProfileChildDescriptor[] = [
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
{ kind: "credential", id: `credential:${profile.id}:smtp`, protocol: "smtp" },
{ kind: "server", id: `server:${profile.id}:imap`, protocol: "imap" }
];
if (profile.imap) children.push({ kind: "credential", id: `credential:${profile.id}:imap`, protocol: "imap" });
return children;
}
export function mailProfileEditTargetInitialSection(target: MailProfileEditTarget): MailProfileEditSection {
if (target.kind === "server" || target.kind === "credentials") return target.protocol;
return "smtp";
}
export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): MailProfilePanelMode | null {
if (target.kind === "create") return "all";
if (target.kind === "server") return "server";
if (target.kind === "credentials") return "credentials";
return null;
}
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
if (target.kind === "create") return ["smtp", "imap"];
return [];
}
export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditTarget): boolean {
return target.kind === "create" || target.kind === "profile";
}
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
return target.kind !== "profile";
}

View File

@@ -17,6 +17,7 @@ export const mailModule: PlatformWebModule = {
label: "i18n:govoplan-mail.mail.92379cbb", label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["addresses"],
translations, translations,
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }], navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [ routes: [

View File

@@ -1,23 +1,8 @@
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.button-icon-label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.mail-profile-manager { .mail-profile-manager {
display: grid; display: grid;
gap: 18px; gap: 18px;
} }
.mail-profile-target-row {
max-width: 520px;
}
.mail-profile-dialog .dialog-body { .mail-profile-dialog .dialog-body {
max-height: min(76vh, 820px); max-height: min(76vh, 820px);
overflow: auto; overflow: auto;
@@ -28,6 +13,39 @@
gap: 18px; gap: 18px;
} }
.mail-profile-transport-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.mail-profile-transport-summary > div {
display: grid;
gap: 4px;
min-width: 0;
padding: 10px 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
.mail-profile-transport-summary span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.mail-profile-transport-summary strong,
.mail-profile-transport-summary small {
min-width: 0;
overflow-wrap: anywhere;
}
.mail-profile-transport-summary small {
color: var(--muted);
}
.mail-profile-form h3, .mail-profile-form h3,
.mail-policy-pattern-grid h4 { .mail-policy-pattern-grid h4 {
margin: 0; margin: 0;
@@ -58,40 +76,6 @@
margin: 0; margin: 0;
} }
.mail-profile-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.mail-profile-checkbox {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.mail-profile-checkbox input {
width: auto;
margin-top: 3px;
box-shadow: none;
}
.mail-profile-checkbox span {
display: grid;
gap: 2px;
min-width: 0;
}
.mail-profile-checkbox small {
color: var(--muted);
overflow-wrap: anywhere;
}
.mail-policy-row { .mail-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr); grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr);
} }
@@ -141,7 +125,7 @@
} }
.mail-policy-effective { .mail-policy-effective {
border-top: 1px solid var(--line); border-top: var(--border-line);
padding-top: 14px; padding-top: 14px;
} }
@@ -155,7 +139,7 @@
display: grid; display: grid;
gap: 3px; gap: 3px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
} }
@@ -171,20 +155,10 @@
color: var(--text-strong); color: var(--text-strong);
} }
.status-badge.success {
background: var(--success-bg);
color: var(--success-text);
}
.status-badge.neutral {
background: #e7e4df;
color: #666;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-form-grid.three-columns,
.mail-policy-pattern-grid, .mail-policy-pattern-grid,
.mail-policy-effective-grid, .mail-policy-effective-grid,
.mail-profile-transport-summary,
.mail-policy-row, .mail-policy-row,
.mail-policy-table.with-effective-column .mail-policy-row, .mail-policy-table.with-effective-column .mail-policy-row,
.mail-policy-table.with-allow-column .mail-policy-row, .mail-policy-table.with-allow-column .mail-policy-row,
@@ -261,7 +235,7 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
width: 100%; width: 100%;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
@@ -356,7 +330,7 @@
margin: 0; margin: 0;
padding: 12px; padding: 12px;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
color: var(--text); color: var(--text);
@@ -380,7 +354,7 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@@ -435,15 +409,6 @@
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */ /* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
.mailbox-page.file-manager-fullscreen {
position: relative;
display: grid;
grid-template-rows: 1fr;
height: calc(100vh - 115px);
padding: 0;
overflow: hidden;
}
.mailbox-shell.file-manager-shell { .mailbox-shell.file-manager-shell {
position: relative; position: relative;
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr); grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
@@ -495,7 +460,7 @@
flex: 0 0 auto; flex: 0 0 auto;
min-width: 20px; min-width: 20px;
padding: 1px 6px; padding: 1px 6px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 999px; border-radius: 999px;
background: var(--surface-subtle); background: var(--surface-subtle);
color: var(--text); color: var(--text);
@@ -546,7 +511,7 @@
} }
.mailbox-message-list-panel { .mailbox-message-list-panel {
border-right: 1px solid var(--line); border-right: var(--border-line);
} }
.mailbox-message-table { .mailbox-message-table {
@@ -568,7 +533,7 @@
width: min(430px, 100%); width: min(430px, 100%);
min-height: 34px; min-height: 34px;
padding: 0 8px; padding: 0 8px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--muted); color: var(--muted);
@@ -582,27 +547,8 @@
padding: 6px 0; padding: 6px 0;
} }
.mailbox-search-field button {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--radius-xs, 5px);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.mailbox-search-field button:hover,
.mailbox-search-field button:focus-visible {
background: var(--surface-subtle);
color: var(--text-strong);
}
.mailbox-error-state { .mailbox-error-state {
color: var(--danger-text, #8f2e2e); color: var(--danger-text);
} }
.mailbox-pagination { .mailbox-pagination {
@@ -672,7 +618,7 @@
.mailbox-preview-panel { .mailbox-preview-panel {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: 320px; min-height: 320px;
border-top: 1px solid var(--line); border-top: var(--border-line);
} }
} }

View File

@@ -0,0 +1,48 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
}
import {
mailProfileChildDescriptors,
mailProfileEditTargetInitialSection,
mailProfileEditTargetPanelMode,
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections
} from "../src/features/mail/mailProfileEditorModel";
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
assertDeepEqual(
smtpOnlyChildren.map((child) => `${child.kind}:${child.protocol}`),
["server:smtp", "credential:smtp", "server:imap"],
"SMTP-only profiles expose SMTP server/credentials and an addable IMAP server"
);
assert(!smtpOnlyChildren.some((child) => child.kind === "credential" && child.protocol === "imap"), "IMAP credentials are hidden until an IMAP server exists");
const fullChildren = mailProfileChildDescriptors({ id: "profile-1", imap: { host: "imap.example.org" } });
assertDeepEqual(
fullChildren.map((child) => `${child.kind}:${child.protocol}`),
["server:smtp", "credential:smtp", "server:imap", "credential:imap"],
"profiles with IMAP expose focused rows for both transports and credentials"
);
assertEqual(mailProfileEditTargetInitialSection({ kind: "server", protocol: "imap" }), "imap");
assertEqual(mailProfileEditTargetPanelMode({ kind: "server", protocol: "smtp" }), "server");
assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "imap" }), "credentials");
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);

View File

@@ -18,8 +18,10 @@
}, },
"include": [ "include": [
"tests/mailbox-folders.test.ts", "tests/mailbox-folders.test.ts",
"tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts", "tests/mail-policy-validation.test.ts",
"src/features/mail/mailboxFolders.ts", "src/features/mail/mailboxFolders.ts",
"src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts" "src/features/mail/mailPolicyValidation.ts"
] ]
} }