Compare commits
12 Commits
b2c013174a
...
a86a779d5b
| Author | SHA1 | Date | |
|---|---|---|---|
| a86a779d5b | |||
| 26932d5ca8 | |||
| b5b5cc8d78 | |||
| 9c0946f8d2 | |||
| 260e579701 | |||
| dea1e85bd3 | |||
| e4594c355d | |||
| f3faa4dff9 | |||
| ff750ed8bd | |||
| e1f298f300 | |||
| 7cf5a69a85 | |||
| e67da95df6 |
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -11,7 +11,7 @@ requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-core>=0.1.9",
|
||||
"pydantic>=2,<3",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
@@ -1399,32 +1399,81 @@ def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normaliz
|
||||
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]:
|
||||
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")
|
||||
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):
|
||||
violations.append(key)
|
||||
for kind in ("whitelist", "blacklist"):
|
||||
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}")
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ from email.message import EmailMessage
|
||||
from email.parser import BytesParser
|
||||
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.dev.mock_mailbox import (
|
||||
MOCK_IMAP_FOLDERS,
|
||||
@@ -25,6 +32,31 @@ from govoplan_mail.backend.dev.mock_mailbox import (
|
||||
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):
|
||||
"""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:
|
||||
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()
|
||||
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
typ, data = client.starttls(ssl_context=context)
|
||||
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]:
|
||||
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":
|
||||
raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True)
|
||||
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
|
||||
|
||||
|
||||
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":
|
||||
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
|
||||
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:
|
||||
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
|
||||
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]:
|
||||
if not sequences:
|
||||
return []
|
||||
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
|
||||
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":
|
||||
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
||||
|
||||
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
|
||||
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
|
||||
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:
|
||||
continue
|
||||
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]:
|
||||
if not uids:
|
||||
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":
|
||||
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
||||
|
||||
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:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
folder = str(record.get("folder") or "").strip()
|
||||
if folder:
|
||||
@@ -834,6 +915,71 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
|
||||
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(
|
||||
*,
|
||||
imap_config: ImapConfig,
|
||||
@@ -848,88 +994,29 @@ def list_imap_messages(
|
||||
host, port = _require_imap_config(imap_config)
|
||||
folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset)
|
||||
if is_mock_imap_host(imap_config.host):
|
||||
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
|
||||
cursor_reset = False
|
||||
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(
|
||||
return _list_mock_imap_messages(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folder=folder,
|
||||
messages=messages,
|
||||
total_count=len(records),
|
||||
offset=effective_offset,
|
||||
limit=limit,
|
||||
uidvalidity="mock-v1",
|
||||
cursor_reset=cursor_reset,
|
||||
offset=offset,
|
||||
after_uid=after_uid,
|
||||
expected_uidvalidity=expected_uidvalidity,
|
||||
)
|
||||
|
||||
client = _open_imap(imap_config)
|
||||
try:
|
||||
total_count, uidvalidity = _select_readonly(client, folder)
|
||||
if after_uid is None and expected_uidvalidity is None:
|
||||
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
|
||||
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
|
||||
return ImapMailboxMessageListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folder=folder,
|
||||
messages=messages,
|
||||
total_count=total_count,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
uidvalidity=None,
|
||||
cursor_reset=False,
|
||||
)
|
||||
uids = _search_message_uids(client)
|
||||
cursor_reset = False
|
||||
effective_after_uid = after_uid
|
||||
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(
|
||||
return _list_imap_messages_on_client(
|
||||
client,
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
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,
|
||||
offset=offset,
|
||||
after_uid=after_uid,
|
||||
expected_uidvalidity=expected_uidvalidity,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,12 @@ from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
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.dev.mock_mailbox import (
|
||||
consume_fail_next_smtp,
|
||||
@@ -19,6 +25,33 @@ from govoplan_mail.backend.dev.mock_mailbox import (
|
||||
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):
|
||||
"""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:
|
||||
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()
|
||||
|
||||
try:
|
||||
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()
|
||||
else:
|
||||
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
|
||||
smtp = _OutboundPolicySMTP(host=host, port=port, timeout=config.timeout_seconds)
|
||||
smtp.ehlo()
|
||||
if config.security == TransportSecurity.STARTTLS:
|
||||
smtp.starttls(context=context)
|
||||
|
||||
@@ -3,20 +3,51 @@ 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 ImapConfig
|
||||
from govoplan_mail.backend.sending.imap import (
|
||||
ImapAppendError,
|
||||
ImapConfigurationError,
|
||||
_detect_sent_folder,
|
||||
_extract_mailbox_name,
|
||||
_fetch_message_by_uid,
|
||||
_normalize_mailbox_page,
|
||||
_open_imap,
|
||||
_paged_descending_sequences,
|
||||
_parse_fetch_sequence,
|
||||
_select_readonly,
|
||||
_sequence_set,
|
||||
append_message_to_sent,
|
||||
list_imap_messages,
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
self.assertEqual(
|
||||
_extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'),
|
||||
@@ -49,6 +80,108 @@ class ImapFolderParserTests(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):
|
||||
self.assertEqual(
|
||||
_paged_descending_sequences(120, offset=0, limit=5),
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
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"):
|
||||
@@ -45,4 +73,3 @@ class SmtpSendHelperTests(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
2
webui/package-lock.json
generated
2
webui/package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-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": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
25
webui/scripts/test-mailbox-icon-button-structure.mjs
Normal file
25
webui/scripts/test-mailbox-icon-button-structure.mjs
Normal 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"
|
||||
);
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -344,10 +344,10 @@ export function MailProfileScopeManager({
|
||||
header: "i18n:govoplan-mail.status.bae7d5be",
|
||||
width: "110px",
|
||||
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" ?
|
||||
<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> :
|
||||
<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={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]);
|
||||
|
||||
@@ -362,24 +362,21 @@ export function MailProfileScopeManager({
|
||||
function renderMailProfileActions(row: MailProfileTreeRow) {
|
||||
if (row.kind === "server") {
|
||||
const label = `Edit ${row.protocol.toUpperCase()} server`;
|
||||
return (
|
||||
<div className="admin-icon-actions">
|
||||
<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>);
|
||||
return <TableActionGroup actions={[{
|
||||
id: "edit-server", label, icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "server", protocol: row.protocol })
|
||||
}]} />;
|
||||
|
||||
}
|
||||
if (row.kind === "credential") {
|
||||
return (
|
||||
<div className="admin-icon-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, { kind: "credentials", protocol: row.protocol })} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button>
|
||||
</div>);
|
||||
return <TableActionGroup 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 })
|
||||
}]} />;
|
||||
|
||||
}
|
||||
return (
|
||||
<div className="admin-icon-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>
|
||||
<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) }
|
||||
]} />;
|
||||
|
||||
}
|
||||
|
||||
@@ -387,7 +384,7 @@ export function MailProfileScopeManager({
|
||||
<div className="mail-profile-manager">
|
||||
{targetSelectionRequired &&
|
||||
<Card title={i18nMessage("i18n:govoplan-mail.value_scope", { value0: targetLabel })}>
|
||||
<div className="mail-profile-target-row">
|
||||
<div className="settings-target-row">
|
||||
<FormField label={targetLabel}>
|
||||
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)} disabled={loading || busy || !hasSelectableTarget}>
|
||||
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
|
||||
@@ -405,7 +402,7 @@ export function MailProfileScopeManager({
|
||||
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 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>
|
||||
}>
|
||||
|
||||
@@ -582,13 +579,6 @@ export function MailProfilePolicyEditor({
|
||||
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) {
|
||||
patchPolicy({ [key]: flagToBoolean(value) });
|
||||
}
|
||||
@@ -658,15 +648,17 @@ export function MailProfilePolicyEditor({
|
||||
</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>
|
||||
<div className="mail-profile-checkbox-grid">
|
||||
{candidateProfiles.map((profile) =>
|
||||
<label className="mail-profile-checkbox" key={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)} />
|
||||
<span><strong>{profile.name}</strong><small>{scopeLabel(profile)} · {transportLabel(profile.smtp)}</small></span>
|
||||
</label>
|
||||
)}
|
||||
{candidateProfiles.length === 0 && <p className="muted small-note">i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85</p>}
|
||||
</div>
|
||||
<AdminSelectionList
|
||||
options={candidateProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: profile.name,
|
||||
description: `${scopeLabel(profile)} · ${transportLabel(profile.smtp)}`,
|
||||
disabled: disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id))
|
||||
}))}
|
||||
selected={[...selectedProfileIds]}
|
||||
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>}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 {
|
||||
Button,
|
||||
DataGridPaginationBar,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
MessageDisplayPanel,
|
||||
formatDateTime,
|
||||
@@ -415,7 +417,7 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
|
||||
<label className="mailbox-search-field">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -474,10 +476,12 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<MailboxPagination
|
||||
<DataGridPaginationBar
|
||||
page={messagePage}
|
||||
pageSize={messagePageSize}
|
||||
totalRows={messageTotalCount ?? 0}
|
||||
className="mailbox-pagination"
|
||||
ariaLabel="i18n:govoplan-mail.mailbox_message_pagination.965407bf"
|
||||
disabled={loadingMessages || !foldersReady}
|
||||
onPageChange={changeMessagePage}
|
||||
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 {
|
||||
return `${folder || "INBOX"}::${uid}`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
|
||||
.mail-profile-target-row {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.mail-profile-dialog .dialog-body {
|
||||
max-height: min(76vh, 820px);
|
||||
overflow: auto;
|
||||
@@ -91,40 +76,6 @@
|
||||
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 {
|
||||
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr);
|
||||
}
|
||||
@@ -204,18 +155,7 @@
|
||||
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) {
|
||||
.admin-form-grid.three-columns,
|
||||
.mail-policy-pattern-grid,
|
||||
.mail-policy-effective-grid,
|
||||
.mail-profile-transport-summary,
|
||||
@@ -469,15 +409,6 @@
|
||||
|
||||
|
||||
/* 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 {
|
||||
position: relative;
|
||||
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
|
||||
@@ -616,25 +547,6 @@
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.mailbox-search-field button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs, 5px);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mailbox-search-field button:hover,
|
||||
.mailbox-search-field button:focus-visible {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.mailbox-error-state {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user