12 Commits

14 changed files with 502 additions and 260 deletions

View File

@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@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

@@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "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

@@ -1399,32 +1399,81 @@ def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normaliz
raise MailProfileError(_policy_parent_lock_message(violations[0])) raise MailProfileError(_policy_parent_lock_message(violations[0]))
def _locked_boolean_policy_fields(
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"):
rules = normalized.get(kind) or {}
if not isinstance(rules, dict):
continue
for key in PROFILE_PATTERN_KEYS:
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"):
local = normalized.get(f"{protocol}_credentials") or {}
local_inherit = local.get("inherit")
limit_key = f"{protocol}_credentials.inherit"
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 {}
if not isinstance(local_lower_limits, dict):
return []
return [
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]: def _policy_parent_lock_violations(parent_limits: dict[str, bool], normalized: dict[str, Any]) -> list[str]:
violations: list[str] = [] violations: list[str] = []
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or []) allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids and not _policy_limit_enabled(parent_limits, "allowed_profile_ids"): if allowed_ids and not _policy_limit_enabled(parent_limits, "allowed_profile_ids"):
violations.append("allowed_profile_ids") violations.append("allowed_profile_ids")
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"): violations.extend(_locked_boolean_policy_fields(parent_limits, normalized))
if isinstance(normalized.get(key), bool) and not _policy_limit_enabled(parent_limits, key): violations.extend(_locked_pattern_policy_fields(parent_limits, normalized))
violations.append(key) violations.extend(_locked_credential_policy_fields(parent_limits, normalized))
for kind in ("whitelist", "blacklist"): violations.extend(_locked_lower_limit_policy_fields(parent_limits, normalized))
rules = normalized.get(kind) or {}
if isinstance(rules, dict):
for key in PROFILE_PATTERN_KEYS:
limit_key = f"{kind}.{key}"
if _clean_string_list(rules.get(key, [])) and not _policy_limit_enabled(parent_limits, limit_key):
violations.append(limit_key)
for protocol in ("smtp", "imap"):
local = normalized.get(f"{protocol}_credentials") or {}
local_inherit = local.get("inherit")
limit_key = f"{protocol}_credentials.inherit"
if isinstance(local_inherit, bool) and not _policy_limit_enabled(parent_limits, limit_key):
violations.append(limit_key)
local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if isinstance(local_lower_limits, dict):
for key, allowed in local_lower_limits.items():
if allowed is True and not _policy_limit_enabled(parent_limits, key):
violations.append(f"allow_lower_level_limits.{key}")
return violations return violations

View File

@@ -12,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,
@@ -25,6 +32,31 @@ from govoplan_mail.backend.dev.mock_mailbox import (
logger = logging.getLogger(__name__) 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."""
@@ -163,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":
@@ -704,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
@@ -734,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)
@@ -791,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)
@@ -812,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:
@@ -834,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,
@@ -848,88 +994,29 @@ def list_imap_messages(
host, port = _require_imap_config(imap_config) host, port = _require_imap_config(imap_config)
folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset) folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=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(
if after_uid is None and expected_uidvalidity is None: client,
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
messages=messages,
total_count=total_count,
offset=offset,
limit=limit,
uidvalidity=None,
cursor_reset=False,
)
uids = _search_message_uids(client)
cursor_reset = False
effective_after_uid = after_uid
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:

View File

@@ -8,6 +8,12 @@ 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,
@@ -19,6 +25,33 @@ from govoplan_mail.backend.dev.mock_mailbox import (
logger = logging.getLogger(__name__) 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."""
@@ -75,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)

View File

@@ -3,20 +3,51 @@ from __future__ import annotations
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import ImapConfig 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, _normalize_mailbox_page,
_open_imap,
_paged_descending_sequences, _paged_descending_sequences,
_parse_fetch_sequence, _parse_fetch_sequence,
_select_readonly, _select_readonly,
_sequence_set, _sequence_set,
append_message_to_sent, 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"'),
@@ -49,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),

View File

@@ -1,16 +1,44 @@
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 SmtpConfig from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import ( from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError, SmtpConfigurationError,
_open_smtp,
_prepare_smtp_send, _prepare_smtp_send,
_smtp_send_result, _smtp_send_result,
) )
class SmtpSendHelperTests(unittest.TestCase): 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): def test_prepare_smtp_send_validates_envelope(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls") config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
with self.assertRaisesRegex(SmtpConfigurationError, "envelope sender"): with self.assertRaisesRegex(SmtpConfigurationError, "envelope sender"):
@@ -45,4 +73,3 @@ class SmtpSendHelperTests(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -11,7 +11,7 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@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

@@ -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.8", "@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-profile-editor-model.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,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 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 {
@@ -344,10 +344,10 @@ export function MailProfileScopeManager({
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"} /> :
row.kind === "server" ? row.kind === "server" ?
<span className={`status-badge ${transportConfigured(row.profile, row.protocol) ? "success" : "neutral"}`}>{transportConfigured(row.profile, row.protocol) ? "i18n:govoplan-core.configured.668c5fff" : "i18n:govoplan-core.not_configured.811931bb"}</span> : <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"} /> :
<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> <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]);
@@ -362,24 +362,21 @@ export function MailProfileScopeManager({
function renderMailProfileActions(row: MailProfileTreeRow) { function renderMailProfileActions(row: MailProfileTreeRow) {
if (row.kind === "server") { if (row.kind === "server") {
const label = `Edit ${row.protocol.toUpperCase()} server`; const label = `Edit ${row.protocol.toUpperCase()} server`;
return ( return <TableActionGroup actions={[{
<div className="admin-icon-actions"> id: "edit-server", label, icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "server", protocol: row.protocol })
<Button type="button" className="admin-icon-button" title={label} aria-label={label} onClick={() => openEdit(row.profile, { kind: "server", protocol: row.protocol })} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button> }]} />;
</div>);
} }
if (row.kind === "credential") { if (row.kind === "credential") {
return ( return <TableActionGroup actions={[{
<div className="admin-icon-actions"> 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" 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, { kind: "credentials", protocol: row.protocol })} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button> }]} />;
</div>);
} }
return ( return <TableActionGroup actions={[
<div className="admin-icon-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) },
<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: "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) }
<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>);
} }
@@ -387,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>}
@@ -405,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>
}> }>
@@ -582,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) });
} }
@@ -658,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>

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,
@@ -415,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>
@@ -474,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} />
@@ -521,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

@@ -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;
@@ -91,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: var(--border-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);
} }
@@ -204,18 +155,7 @@
color: var(--text-strong); color: var(--text-strong);
} }
.status-badge.success {
background: var(--success-bg);
color: var(--success-text);
}
.status-badge.neutral {
background: var(--status-neutral-bg);
color: var(--text-soft);
}
@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-profile-transport-summary,
@@ -469,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);
@@ -616,25 +547,6 @@
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); color: var(--danger-text);
} }