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"
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
2
webui/package-lock.json
generated
2
webui/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user