Prepare v0.1.0 release
This commit is contained in:
@@ -49,3 +49,7 @@ Frontend package:
|
|||||||
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
|
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
|
||||||
|
|
||||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||||
|
|
||||||
|
## Release packaging
|
||||||
|
|
||||||
|
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/mail-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths.
|
||||||
|
|||||||
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/mail-webui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/mail-profiles.css": "./webui/src/styles/mail-profiles.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.0",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,40 @@ def _policy_from_attr(value: Any) -> dict[str, Any]:
|
|||||||
return normalize_mail_profile_policy(value if isinstance(value, dict) else {})
|
return normalize_mail_profile_policy(value if isinstance(value, dict) else {})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False) -> list[str]:
|
||||||
|
fields: list[str] = []
|
||||||
|
if _meaningful_allow_patterns(policy.get("allowed_profile_ids") or []):
|
||||||
|
fields.append("allowed_profile_ids")
|
||||||
|
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
|
||||||
|
if isinstance(policy.get(key), bool):
|
||||||
|
fields.append(key)
|
||||||
|
for key in ("smtp_credentials", "imap_credentials"):
|
||||||
|
credential = policy.get(key) or {}
|
||||||
|
if isinstance(credential, dict):
|
||||||
|
if isinstance(credential.get("inherit"), bool):
|
||||||
|
fields.append(f"{key}.inherit")
|
||||||
|
if isinstance(credential.get("allow_override"), bool):
|
||||||
|
fields.append(f"{key}.allow_override")
|
||||||
|
for kind in ("whitelist", "blacklist"):
|
||||||
|
rules = policy.get(kind) or {}
|
||||||
|
if isinstance(rules, dict):
|
||||||
|
for key in PROFILE_PATTERN_KEYS:
|
||||||
|
if _clean_string_list(rules.get(key, [])):
|
||||||
|
fields.append(f"{kind}.{key}")
|
||||||
|
if baseline and not fields:
|
||||||
|
fields.append("defaults")
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope_type": source,
|
||||||
|
"scope_id": source_id,
|
||||||
|
"label": label,
|
||||||
|
"applied_fields": _mail_policy_source_fields(policy, baseline=baseline),
|
||||||
|
}
|
||||||
|
|
||||||
def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None:
|
def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None:
|
||||||
inherit = data.get("inherit")
|
inherit = data.get("inherit")
|
||||||
if isinstance(inherit, bool):
|
if isinstance(inherit, bool):
|
||||||
@@ -216,9 +250,9 @@ def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str,
|
|||||||
current.allow_override = False
|
current.allow_override = False
|
||||||
|
|
||||||
|
|
||||||
def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str) -> None:
|
def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str, source_id: str | None = None, label: str | None = None, baseline: bool = False) -> None:
|
||||||
normalized = normalize_mail_profile_policy(data)
|
normalized = normalize_mail_profile_policy(data)
|
||||||
policy.source_policies.append(normalized)
|
policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline))
|
||||||
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:
|
if allowed_ids:
|
||||||
policy.allowed_profile_id_sets.append(set(allowed_ids))
|
policy.allowed_profile_id_sets.append(set(allowed_ids))
|
||||||
@@ -280,18 +314,18 @@ def effective_mail_profile_policy(
|
|||||||
|
|
||||||
policy = EffectiveMailProfilePolicy()
|
policy = EffectiveMailProfilePolicy()
|
||||||
system_settings = get_system_settings(session)
|
system_settings = get_system_settings(session)
|
||||||
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system")
|
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
|
||||||
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant")
|
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant")
|
||||||
if owner_user_id:
|
if owner_user_id:
|
||||||
user = session.get(User, owner_user_id)
|
user = session.get(User, owner_user_id)
|
||||||
if user and user.tenant_id == tenant_id:
|
if user and user.tenant_id == tenant_id:
|
||||||
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user")
|
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user")
|
||||||
if owner_group_id:
|
if owner_group_id:
|
||||||
group = session.get(Group, owner_group_id)
|
group = session.get(Group, owner_group_id)
|
||||||
if group and group.tenant_id == tenant_id:
|
if group and group.tenant_id == tenant_id:
|
||||||
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group")
|
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group")
|
||||||
if campaign is not None:
|
if campaign is not None:
|
||||||
_merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign")
|
_merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign")
|
||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
@@ -309,11 +343,11 @@ def parent_mail_profile_policy(
|
|||||||
|
|
||||||
policy = EffectiveMailProfilePolicy()
|
policy = EffectiveMailProfilePolicy()
|
||||||
system_settings = get_system_settings(session)
|
system_settings = get_system_settings(session)
|
||||||
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system")
|
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
|
||||||
if clean_scope == "tenant":
|
if clean_scope == "tenant":
|
||||||
return policy
|
return policy
|
||||||
|
|
||||||
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant")
|
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant")
|
||||||
if clean_scope in {"user", "group"}:
|
if clean_scope in {"user", "group"}:
|
||||||
return policy
|
return policy
|
||||||
|
|
||||||
@@ -325,11 +359,11 @@ def parent_mail_profile_policy(
|
|||||||
if campaign.owner_user_id:
|
if campaign.owner_user_id:
|
||||||
user = session.get(User, campaign.owner_user_id)
|
user = session.get(User, campaign.owner_user_id)
|
||||||
if user and user.tenant_id == tenant_id:
|
if user and user.tenant_id == tenant_id:
|
||||||
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user")
|
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user")
|
||||||
if campaign.owner_group_id:
|
if campaign.owner_group_id:
|
||||||
group = session.get(Group, campaign.owner_group_id)
|
group = session.get(Group, campaign.owner_group_id)
|
||||||
if group and group.tenant_id == tenant_id:
|
if group and group.tenant_id == tenant_id:
|
||||||
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group")
|
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group")
|
||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
@@ -454,7 +488,7 @@ def _ensure_scope_allows_profile_creation(
|
|||||||
if scope_type == "campaign":
|
if scope_type == "campaign":
|
||||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id)
|
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id)
|
||||||
if not policy.allow_campaign_profiles:
|
if not policy.allow_campaign_profiles:
|
||||||
raise MailProfileError("Campaign-scoped mail-server profiles are disabled by policy")
|
raise MailProfileError("Campaign-local mail profiles are disabled by policy")
|
||||||
return
|
return
|
||||||
if scope_type == "user":
|
if scope_type == "user":
|
||||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, owner_user_id=scope_id)
|
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, owner_user_id=scope_id)
|
||||||
@@ -694,6 +728,17 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p
|
|||||||
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
|
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_transport_configured(value: Any) -> bool:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
if value.get("enabled") is True:
|
||||||
|
return True
|
||||||
|
for key in ("host", "username", "password"):
|
||||||
|
if str(value.get(key) or "").strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def assert_campaign_mail_policy_allows_json(
|
def assert_campaign_mail_policy_allows_json(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -737,6 +782,12 @@ def assert_campaign_mail_policy_allows_json(
|
|||||||
return
|
return
|
||||||
smtp = server.get("smtp") if isinstance(server, dict) and isinstance(server.get("smtp"), dict) else None
|
smtp = server.get("smtp") if isinstance(server, dict) and isinstance(server.get("smtp"), dict) else None
|
||||||
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
|
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
|
||||||
|
if campaign_id:
|
||||||
|
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||||
|
if not policy.allow_campaign_profiles and (_inline_transport_configured(smtp) or _inline_transport_configured(imap)):
|
||||||
|
raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
|
||||||
|
_assert_transport_values_allowed(policy, smtp, imap)
|
||||||
|
return
|
||||||
assert_mail_policy_allows_transport(
|
assert_mail_policy_allows_transport(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from govoplan_mail.backend.mail_profiles import (
|
|||||||
get_mail_server_profile,
|
get_mail_server_profile,
|
||||||
imap_config_from_profile,
|
imap_config_from_profile,
|
||||||
list_mail_server_profiles,
|
list_mail_server_profiles,
|
||||||
|
parent_mail_profile_policy,
|
||||||
profile_response_payload,
|
profile_response_payload,
|
||||||
set_mail_profile_policy,
|
set_mail_profile_policy,
|
||||||
smtp_config_from_profile,
|
smtp_config_from_profile,
|
||||||
@@ -138,6 +139,12 @@ def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: st
|
|||||||
return imap
|
return imap
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
|
||||||
|
if scope_type == "system":
|
||||||
|
return None
|
||||||
|
return parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id).as_dict()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles", response_model=MailServerProfileListResponse)
|
@router.get("/profiles", response_model=MailServerProfileListResponse)
|
||||||
def list_profiles(
|
def list_profiles(
|
||||||
include_inactive: bool = False,
|
include_inactive: bool = False,
|
||||||
@@ -267,11 +274,19 @@ def read_mail_profile_policy(
|
|||||||
try:
|
try:
|
||||||
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
|
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
effective = None
|
effective = None
|
||||||
|
effective_sources = []
|
||||||
if campaign_id:
|
if campaign_id:
|
||||||
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id).as_dict()
|
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
|
||||||
|
effective = effective_policy.as_dict()
|
||||||
|
effective_sources = effective_policy.source_policies
|
||||||
elif scope_type == "campaign" and scope_id:
|
elif scope_type == "campaign" and scope_id:
|
||||||
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
|
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
|
||||||
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
|
effective = effective_policy.as_dict()
|
||||||
|
effective_sources = effective_policy.source_policies
|
||||||
|
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
||||||
|
parent = parent_policy.as_dict() if parent_policy else None
|
||||||
|
parent_sources = parent_policy.source_policies if parent_policy else []
|
||||||
|
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -296,9 +311,15 @@ def write_mail_profile_policy(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
effective = None
|
effective = None
|
||||||
|
effective_sources = []
|
||||||
if scope_type == "campaign" and scope_id:
|
if scope_type == "campaign" and scope_id:
|
||||||
effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict()
|
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
|
||||||
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective)
|
effective = effective_policy.as_dict()
|
||||||
|
effective_sources = effective_policy.source_policies
|
||||||
|
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
||||||
|
parent = parent_policy.as_dict() if parent_policy else None
|
||||||
|
parent_sources = parent_policy.source_policies if parent_policy else []
|
||||||
|
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
@@ -404,6 +425,7 @@ def list_profile_mailbox_messages(
|
|||||||
host=result.host,
|
host=result.host,
|
||||||
port=result.port,
|
port=result.port,
|
||||||
security=result.security,
|
security=result.security,
|
||||||
|
total_count=result.total_count,
|
||||||
messages=[_mailbox_summary_response(message) for message in result.messages],
|
messages=[_mailbox_summary_response(message) for message in result.messages],
|
||||||
)
|
)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
|
|||||||
@@ -47,11 +47,21 @@ class MailProfilePolicyUpdateRequest(BaseModel):
|
|||||||
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
|
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicySourceStepResponse(BaseModel):
|
||||||
|
scope_type: str
|
||||||
|
scope_id: str | None = None
|
||||||
|
label: str
|
||||||
|
applied_fields: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyResponse(BaseModel):
|
class MailProfilePolicyResponse(BaseModel):
|
||||||
scope_type: MailProfileScope
|
scope_type: MailProfileScope
|
||||||
scope_id: str | None = None
|
scope_id: str | None = None
|
||||||
policy: dict[str, Any]
|
policy: dict[str, Any]
|
||||||
effective_policy: dict[str, Any] | None = None
|
effective_policy: dict[str, Any] | None = None
|
||||||
|
parent_policy: dict[str, Any] | None = None
|
||||||
|
effective_policy_sources: list[PolicySourceStepResponse] = Field(default_factory=list)
|
||||||
|
parent_policy_sources: list[PolicySourceStepResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MailServerProfileCreateRequest(BaseModel):
|
class MailServerProfileCreateRequest(BaseModel):
|
||||||
@@ -160,6 +170,7 @@ class MailMailboxMessageListResponse(BaseModel):
|
|||||||
host: str | None = None
|
host: str | None = None
|
||||||
port: int | None = None
|
port: int | None = None
|
||||||
security: str | None = None
|
security: str | None = None
|
||||||
|
total_count: int = 0
|
||||||
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class ImapMailboxMessageListResult:
|
|||||||
security: str
|
security: str
|
||||||
folder: str
|
folder: str
|
||||||
messages: list[ImapMailboxMessageSummary]
|
messages: list[ImapMailboxMessageSummary]
|
||||||
|
total_count: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -512,7 +513,7 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
|
|||||||
)
|
)
|
||||||
for record in records[:limit]
|
for record in records[:limit]
|
||||||
]
|
]
|
||||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages)
|
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records))
|
||||||
|
|
||||||
client = _open_imap(imap_config)
|
client = _open_imap(imap_config)
|
||||||
try:
|
try:
|
||||||
@@ -526,7 +527,7 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
|
|||||||
for uid in reversed(uids[-limit:]):
|
for uid in reversed(uids[-limit:]):
|
||||||
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
|
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
|
||||||
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
|
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
|
||||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages)
|
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
client.logout()
|
client.logout()
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export type MailMailboxMessageListResponse = {
|
|||||||
host?: string | null;
|
host?: string | null;
|
||||||
port?: number | null;
|
port?: number | null;
|
||||||
security?: MailSecurity | string | null;
|
security?: MailSecurity | string | null;
|
||||||
|
total_count?: number;
|
||||||
messages: MailMailboxMessageSummary[];
|
messages: MailMailboxMessageSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,11 +139,21 @@ export type MailProfilePolicy = {
|
|||||||
blacklist?: MailProfilePatternRules | null;
|
blacklist?: MailProfilePatternRules | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PolicySourceStep = {
|
||||||
|
scope_type: string;
|
||||||
|
scope_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
applied_fields?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type MailProfilePolicyResponse = {
|
export type MailProfilePolicyResponse = {
|
||||||
scope_type: MailProfileScope;
|
scope_type: MailProfileScope;
|
||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
policy: MailProfilePolicy;
|
policy: MailProfilePolicy;
|
||||||
effective_policy?: MailProfilePolicy | null;
|
effective_policy?: MailProfilePolicy | null;
|
||||||
|
parent_policy?: MailProfilePolicy | null;
|
||||||
|
effective_policy_sources?: PolicySourceStep[];
|
||||||
|
parent_policy_sources?: PolicySourceStep[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailServerProfilePayload = {
|
export type MailServerProfilePayload = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
import { EffectivePolicyBlock, EffectivePolicyValue, MailServerSettingsPanel, PolicyLockedHint, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, 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 {
|
||||||
@@ -329,6 +329,8 @@ export function MailProfilePolicyEditor({
|
|||||||
}: MailProfilePolicyEditorProps) {
|
}: MailProfilePolicyEditorProps) {
|
||||||
const [policy, setPolicy] = useState<MailProfilePolicy>(blankPolicy);
|
const [policy, setPolicy] = useState<MailProfilePolicy>(blankPolicy);
|
||||||
const [effectivePolicy, setEffectivePolicy] = useState<MailProfilePolicy | null>(null);
|
const [effectivePolicy, setEffectivePolicy] = useState<MailProfilePolicy | null>(null);
|
||||||
|
const [parentPolicy, setParentPolicy] = useState<MailProfilePolicy | null>(null);
|
||||||
|
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -345,6 +347,8 @@ export function MailProfilePolicyEditor({
|
|||||||
if (!scopeReady) {
|
if (!scopeReady) {
|
||||||
setPolicy(blankPolicy);
|
setPolicy(blankPolicy);
|
||||||
setEffectivePolicy(null);
|
setEffectivePolicy(null);
|
||||||
|
setParentPolicy(null);
|
||||||
|
setEffectivePolicySources([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -352,9 +356,13 @@ export function MailProfilePolicyEditor({
|
|||||||
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
|
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
|
||||||
setPolicy(normalizePolicy(response.policy));
|
setPolicy(normalizePolicy(response.policy));
|
||||||
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
|
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
|
||||||
|
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
|
||||||
|
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPolicy(blankPolicy);
|
setPolicy(blankPolicy);
|
||||||
setEffectivePolicy(null);
|
setEffectivePolicy(null);
|
||||||
|
setParentPolicy(null);
|
||||||
|
setEffectivePolicySources([]);
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -370,6 +378,8 @@ export function MailProfilePolicyEditor({
|
|||||||
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicy(policy), scopeId);
|
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicy(policy), scopeId);
|
||||||
setPolicy(normalizePolicy(response.policy));
|
setPolicy(normalizePolicy(response.policy));
|
||||||
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
|
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
|
||||||
|
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
|
||||||
|
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||||
setSuccess("Mail profile policy saved.");
|
setSuccess("Mail profile policy saved.");
|
||||||
await onSaved?.();
|
await onSaved?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -385,6 +395,18 @@ export function MailProfilePolicyEditor({
|
|||||||
);
|
);
|
||||||
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
|
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
|
||||||
const disabled = locked || busy || loading || !canWrite || !scopeReady;
|
const disabled = locked || busy || loading || !canWrite || !scopeReady;
|
||||||
|
const parentAllowedProfileIds = parentPolicy?.allowed_profile_ids?.length ? new Set(parentPolicy.allowed_profile_ids) : null;
|
||||||
|
const parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false;
|
||||||
|
const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false;
|
||||||
|
const parentBlocksCampaignProfiles = parentPolicy?.allow_campaign_profiles === false;
|
||||||
|
const smtpCredentialLocked = parentPolicy?.smtp_credentials?.allow_override === false;
|
||||||
|
const imapCredentialLocked = parentPolicy?.imap_credentials?.allow_override === false;
|
||||||
|
const blockedProfileDefinitions = [
|
||||||
|
parentBlocksUserProfiles ? "user" : "",
|
||||||
|
parentBlocksGroupProfiles ? "group" : "",
|
||||||
|
parentBlocksCampaignProfiles ? "campaign-local settings" : ""
|
||||||
|
].filter(Boolean).join(", ");
|
||||||
|
const lockedCredentialKinds = [smtpCredentialLocked ? "SMTP" : "", imapCredentialLocked ? "IMAP" : ""].filter(Boolean).join(" and ");
|
||||||
|
|
||||||
function patchPolicy(patch: Partial<MailProfilePolicy>) {
|
function patchPolicy(patch: Partial<MailProfilePolicy>) {
|
||||||
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
|
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
|
||||||
@@ -438,31 +460,34 @@ export function MailProfilePolicyEditor({
|
|||||||
<div className="mail-profile-checkbox-grid">
|
<div className="mail-profile-checkbox-grid">
|
||||||
{candidateProfiles.map((profile) => (
|
{candidateProfiles.map((profile) => (
|
||||||
<label className="mail-profile-checkbox" key={profile.id}>
|
<label className="mail-profile-checkbox" key={profile.id}>
|
||||||
<input type="checkbox" checked={selectedProfileIds.has(profile.id)} disabled={disabled} onChange={(event) => toggleAllowedProfile(profile.id, event.target.checked)} />
|
<input type="checkbox" checked={selectedProfileIds.has(profile.id)} disabled={disabled || 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>
|
<span><strong>{profile.name}</strong><small>{scopeLabel(profile)} · {transportLabel(profile.smtp)}</small></span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{candidateProfiles.length === 0 && <p className="muted small-note">No profiles are visible for this policy scope.</p>}
|
{candidateProfiles.length === 0 && <p className="muted small-note">No profiles are visible for this policy scope.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{parentAllowedProfileIds && <p className="muted small-note">An ancestor allow-list limits selectable profiles to {parentAllowedProfileIds.size} profile(s).</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mail-policy-section">
|
<section className="mail-policy-section">
|
||||||
<h3>Lower-level profile definitions</h3>
|
<h3>Lower-level mail definitions</h3>
|
||||||
<div className="admin-form-grid three-columns mail-policy-flags">
|
<div className="admin-form-grid three-columns mail-policy-flags">
|
||||||
<PolicyFlagSelect label="User profiles" value={booleanToFlag(policy.allow_user_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_user_profiles", value)} />
|
<PolicyFlagSelect label="User profiles" value={booleanToFlag(policy.allow_user_profiles)} disabled={disabled} allowDisabled={parentBlocksUserProfiles} onChange={(value) => setFlag("allow_user_profiles", value)} />
|
||||||
<PolicyFlagSelect label="Group profiles" value={booleanToFlag(policy.allow_group_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_group_profiles", value)} />
|
<PolicyFlagSelect label="Group profiles" value={booleanToFlag(policy.allow_group_profiles)} disabled={disabled} allowDisabled={parentBlocksGroupProfiles} onChange={(value) => setFlag("allow_group_profiles", value)} />
|
||||||
<PolicyFlagSelect label="Campaign profiles" value={booleanToFlag(policy.allow_campaign_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_campaign_profiles", value)} />
|
<PolicyFlagSelect label="Campaign-local settings" value={booleanToFlag(policy.allow_campaign_profiles)} disabled={disabled} allowDisabled={parentBlocksCampaignProfiles} onChange={(value) => setFlag("allow_campaign_profiles", value)} />
|
||||||
</div>
|
</div>
|
||||||
|
{blockedProfileDefinitions && <PolicyLockedHint>Explicit allow is unavailable for {blockedProfileDefinitions} because an ancestor policy blocks those definitions.</PolicyLockedHint>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mail-policy-section">
|
<section className="mail-policy-section">
|
||||||
<h3>Credential inheritance</h3>
|
<h3>Credential inheritance</h3>
|
||||||
<div className="admin-form-grid two-columns mail-policy-flags">
|
<div className="admin-form-grid two-columns mail-policy-flags">
|
||||||
<CredentialInheritanceSelect label="SMTP credentials" value={credentialInheritanceToValue(policy.smtp_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />
|
<CredentialInheritanceSelect label="SMTP credentials" value={credentialInheritanceToValue(policy.smtp_credentials?.inherit)} disabled={disabled || smtpCredentialLocked} onChange={(value) => setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />
|
||||||
<CredentialOverrideSelect label="SMTP override" value={credentialOverrideToValue(policy.smtp_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} />
|
<CredentialOverrideSelect label="SMTP override" value={credentialOverrideToValue(policy.smtp_credentials?.allow_override)} disabled={disabled || smtpCredentialLocked} onChange={(value) => setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} />
|
||||||
<CredentialInheritanceSelect label="IMAP credentials" value={credentialInheritanceToValue(policy.imap_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />
|
<CredentialInheritanceSelect label="IMAP credentials" value={credentialInheritanceToValue(policy.imap_credentials?.inherit)} disabled={disabled || imapCredentialLocked} onChange={(value) => setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />
|
||||||
<CredentialOverrideSelect label="IMAP override" value={credentialOverrideToValue(policy.imap_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} />
|
<CredentialOverrideSelect label="IMAP override" value={credentialOverrideToValue(policy.imap_credentials?.allow_override)} disabled={disabled || imapCredentialLocked} onChange={(value) => setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} />
|
||||||
</div>
|
</div>
|
||||||
|
{lockedCredentialKinds && <PolicyLockedHint>{lockedCredentialKinds} credential inheritance is locked by an ancestor policy.</PolicyLockedHint>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mail-policy-section">
|
<section className="mail-policy-section">
|
||||||
@@ -480,17 +505,19 @@ export function MailProfilePolicyEditor({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{effectivePolicy && (
|
{effectivePolicy && (
|
||||||
<section className="mail-policy-section mail-policy-effective">
|
<EffectivePolicyBlock
|
||||||
<h3>Effective campaign policy</h3>
|
title="Effective mail policy"
|
||||||
<div className="mail-policy-effective-grid">
|
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType)}
|
||||||
<div><span>Profiles</span><strong>{effectiveProfileLabel(effectivePolicy.allowed_profile_ids)}</strong></div>
|
className="mail-policy-section mail-policy-effective"
|
||||||
<div><span>User profiles</span><strong>{effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"}</strong></div>
|
gridClassName="mail-policy-effective-grid"
|
||||||
<div><span>Group profiles</span><strong>{effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"}</strong></div>
|
>
|
||||||
<div><span>Campaign profiles</span><strong>{effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"}</strong></div>
|
<EffectivePolicyValue label="Profiles" value={effectiveProfileLabel(effectivePolicy.allowed_profile_ids)} />
|
||||||
<div><span>SMTP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.smtp_credentials)}</strong></div>
|
<EffectivePolicyValue label="User profiles" value={effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"} />
|
||||||
<div><span>IMAP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.imap_credentials)}</strong></div>
|
<EffectivePolicyValue label="Group profiles" value={effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"} />
|
||||||
</div>
|
<EffectivePolicyValue label="Campaign-local" value={effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"} />
|
||||||
</section>
|
<EffectivePolicyValue label="SMTP credentials" value={credentialPolicyLabel(effectivePolicy.smtp_credentials)} />
|
||||||
|
<EffectivePolicyValue label="IMAP credentials" value={credentialPolicyLabel(effectivePolicy.imap_credentials)} />
|
||||||
|
</EffectivePolicyBlock>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -626,9 +653,11 @@ function ProfileForm({
|
|||||||
onImapEnabledChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })}
|
onImapEnabledChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })}
|
||||||
smtpDisabled={disabled}
|
smtpDisabled={disabled}
|
||||||
smtpCredentialDisabled={credentialDisabled}
|
smtpCredentialDisabled={credentialDisabled}
|
||||||
|
smtpPasswordSaved={Boolean(existingProfile?.smtp_password_configured)}
|
||||||
imapToggleDisabled={imapToggleDisabled}
|
imapToggleDisabled={imapToggleDisabled}
|
||||||
imapServerDisabled={disabled || !draft.imapEnabled}
|
imapServerDisabled={disabled || !draft.imapEnabled}
|
||||||
imapCredentialDisabled={credentialDisabled || !draft.imapEnabled}
|
imapCredentialDisabled={credentialDisabled || !draft.imapEnabled}
|
||||||
|
imapPasswordSaved={Boolean(existingProfile?.imap_password_configured)}
|
||||||
imapActionDisabled={disabled || !draft.imapEnabled}
|
imapActionDisabled={disabled || !draft.imapEnabled}
|
||||||
smtpTitle="SMTP"
|
smtpTitle="SMTP"
|
||||||
imapTitle="IMAP"
|
imapTitle="IMAP"
|
||||||
@@ -648,12 +677,12 @@ function ProfileForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PolicyFlagSelect({ label, value, disabled, onChange }: { label: string; value: PolicyFlagValue; disabled: boolean; onChange: (value: PolicyFlagValue) => void }) {
|
function PolicyFlagSelect({ label, value, disabled, allowDisabled = false, onChange }: { label: string; value: PolicyFlagValue; disabled: boolean; allowDisabled?: boolean; onChange: (value: PolicyFlagValue) => void }) {
|
||||||
return (
|
return (
|
||||||
<FormField label={label}>
|
<FormField label={label}>
|
||||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
|
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
|
||||||
<option value="inherit">Inherit</option>
|
<option value="inherit">Inherit</option>
|
||||||
<option value="allow">Explicit allow</option>
|
<option value="allow" disabled={allowDisabled}>Explicit allow</option>
|
||||||
<option value="deny">Deny</option>
|
<option value="deny">Deny</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -916,6 +945,14 @@ function effectiveProfileLabel(value: string[] | null | undefined): string {
|
|||||||
return `${value.length} profile(s)`;
|
return `${value.length} profile(s)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mailPolicySourcePath(scopeType: MailProfileScope): string[] {
|
||||||
|
if (scopeType === "system") return ["System"];
|
||||||
|
if (scopeType === "tenant") return ["System", "Tenant"];
|
||||||
|
if (scopeType === "user") return ["System", "Tenant", "User"];
|
||||||
|
if (scopeType === "group") return ["System", "Tenant", "Group"];
|
||||||
|
return ["System", "Tenant", "Owner policy", "Campaign"];
|
||||||
|
}
|
||||||
|
|
||||||
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | null | undefined): string {
|
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | null | undefined): string {
|
||||||
if (!transport) return "Not configured";
|
if (!transport) return "Not configured";
|
||||||
const host = transport.host || "No host";
|
const host = transport.host || "No host";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
|
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -33,25 +33,38 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||||
const [selectedFolder, setSelectedFolder] = useState("INBOX");
|
const [selectedFolder, setSelectedFolder] = useState("INBOX");
|
||||||
|
const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState("");
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||||
|
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
||||||
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
|
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
|
||||||
|
const [pendingMessageKey, setPendingMessageKey] = useState("");
|
||||||
const [loadingProfiles, setLoadingProfiles] = useState(true);
|
const [loadingProfiles, setLoadingProfiles] = useState(true);
|
||||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const [loadingMessage, setLoadingMessage] = useState(false);
|
const [loadingMessage, setLoadingMessage] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const folderRequestRef = useRef(0);
|
||||||
|
const messageListRequestRef = useRef(0);
|
||||||
|
const messageDetailRequestRef = useRef(0);
|
||||||
|
|
||||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||||
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
|
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
|
||||||
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
|
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
|
||||||
const busy = loadingProfiles || loadingFolders || loadingMessages || loadingMessage;
|
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
|
||||||
|
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
|
||||||
|
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
||||||
|
const selectedMessageKey = selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : pendingMessageKey;
|
||||||
|
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
|
||||||
|
const folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.";
|
||||||
|
const messageEmptyText = !selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : "No messages in this folder.";
|
||||||
|
const previewEmptyText = loadingMessage ? "Loading message..." : "Select a message to inspect its content.";
|
||||||
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
|
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
|
||||||
|
|
||||||
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
|
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
|
||||||
useEffect(() => { if (selectedProfileId && selectedFolder) void loadMessages(selectedProfileId, selectedFolder); }, [selectedProfileId, selectedFolder]);
|
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder); }, [foldersReady, selectedProfileId, selectedFolder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
|
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
|
||||||
@@ -73,8 +86,11 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
||||||
if (usable.length === 0) {
|
if (usable.length === 0) {
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setMessageTotalCount(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setPendingMessageKey("");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
@@ -85,71 +101,112 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
|
|
||||||
async function loadFolders(profileId = selectedProfileId) {
|
async function loadFolders(profileId = selectedProfileId) {
|
||||||
if (!profileId) return;
|
if (!profileId) return;
|
||||||
|
const requestId = ++folderRequestRef.current;
|
||||||
|
messageListRequestRef.current += 1;
|
||||||
|
messageDetailRequestRef.current += 1;
|
||||||
setLoadingFolders(true);
|
setLoadingFolders(true);
|
||||||
|
setFoldersLoadedForProfile("");
|
||||||
|
setMessageTotalCount(null);
|
||||||
|
setPendingMessageKey("");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await listMailboxFolders(settings, profileId);
|
const response = await listMailboxFolders(settings, profileId);
|
||||||
|
if (requestId !== folderRequestRef.current) return;
|
||||||
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
|
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
|
||||||
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
|
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
|
||||||
setFolders(loaded);
|
setFolders(loaded);
|
||||||
setExpandedFolders(new Set());
|
setExpandedFolders(new Set());
|
||||||
setSelectedFolder((current) => {
|
let nextFolder = selectedFolder;
|
||||||
if (current && loaded.some((folder) => folder.name === current)) return current;
|
if (!nextFolder || !loaded.some((folder) => folder.name === nextFolder)) {
|
||||||
if (loaded.some((folder) => folder.name === "INBOX")) return "INBOX";
|
nextFolder = loaded.some((folder) => folder.name === "INBOX") ? "INBOX" : response.detected_sent_folder || loaded[0]?.name || "INBOX";
|
||||||
return response.detected_sent_folder || loaded[0]?.name || "INBOX";
|
}
|
||||||
});
|
setSelectedFolder(nextFolder);
|
||||||
|
setFoldersLoadedForProfile(profileId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== folderRequestRef.current) return;
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setMessageTotalCount(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setPendingMessageKey("");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingFolders(false);
|
if (requestId === folderRequestRef.current) setLoadingFolders(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
|
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
|
||||||
if (!profileId || !folder) return;
|
if (!profileId || !folder) return;
|
||||||
|
const requestId = ++messageListRequestRef.current;
|
||||||
|
messageDetailRequestRef.current += 1;
|
||||||
setLoadingMessages(true);
|
setLoadingMessages(true);
|
||||||
|
setMessageTotalCount(null);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setPendingMessageKey("");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await listMailboxMessages(settings, profileId, folder, 50);
|
const response = await listMailboxMessages(settings, profileId, folder, 50);
|
||||||
setMessages(response.messages ?? []);
|
if (requestId !== messageListRequestRef.current) return;
|
||||||
|
const loaded = response.messages ?? [];
|
||||||
|
setMessages(loaded);
|
||||||
|
setMessageTotalCount(response.total_count ?? loaded.length);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== messageListRequestRef.current) return;
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setMessageTotalCount(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setPendingMessageKey("");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMessages(false);
|
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openMessage(message: MailMailboxMessageSummary) {
|
async function openMessage(message: MailMailboxMessageSummary) {
|
||||||
if (!selectedProfileId || loadingMessage) return;
|
if (!selectedProfileId) return;
|
||||||
|
const folderName = message.folder || selectedFolder;
|
||||||
|
const requestId = ++messageDetailRequestRef.current;
|
||||||
|
setPendingMessageKey(mailboxMessageKey(folderName, message.uid));
|
||||||
|
setSelectedMessage(null);
|
||||||
setLoadingMessage(true);
|
setLoadingMessage(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await getMailboxMessage(settings, selectedProfileId, selectedFolder, message.uid);
|
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
|
||||||
|
if (requestId !== messageDetailRequestRef.current) return;
|
||||||
setSelectedMessage(response.message);
|
setSelectedMessage(response.message);
|
||||||
|
setPendingMessageKey("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== messageDetailRequestRef.current) return;
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
|
setPendingMessageKey("");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMessage(false);
|
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProfile(profileId: string) {
|
function selectProfile(profileId: string) {
|
||||||
|
folderRequestRef.current += 1;
|
||||||
|
messageListRequestRef.current += 1;
|
||||||
|
messageDetailRequestRef.current += 1;
|
||||||
setSelectedProfileId(profileId);
|
setSelectedProfileId(profileId);
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setMessageTotalCount(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setPendingMessageKey("");
|
||||||
setExpandedFolders(new Set());
|
setExpandedFolders(new Set());
|
||||||
|
setLoadingFolders(false);
|
||||||
|
setLoadingMessages(false);
|
||||||
|
setLoadingMessage(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFolderNode(node: MailFolderNode) {
|
function openFolderNode(node: MailFolderNode) {
|
||||||
if (node.folderName) {
|
if (node.folderName) {
|
||||||
setSelectedFolder(node.folderName);
|
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName);
|
||||||
|
else setSelectedFolder(node.folderName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toggleFolderNode(node);
|
toggleFolderNode(node);
|
||||||
@@ -168,11 +225,11 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
|
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
|
||||||
<div className={`file-manager-shell mailbox-shell ${busy ? "is-loading" : ""}`}>
|
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
|
||||||
<aside className="file-tree-panel" aria-label="Mailbox folders">
|
<aside className="file-tree-panel" aria-label="Mailbox folders">
|
||||||
<div className="file-tree-heading">Folders</div>
|
<div className="file-tree-heading">Folders</div>
|
||||||
<div className="file-tree-list">
|
<div className="file-tree-list">
|
||||||
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">No folders available.</p>}
|
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
|
||||||
<ExplorerTree
|
<ExplorerTree
|
||||||
nodes={folderTree}
|
nodes={folderTree}
|
||||||
getNodeId={(node) => node.id}
|
getNodeId={(node) => node.id}
|
||||||
@@ -182,13 +239,20 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
expandedIds={expandedFolders}
|
expandedIds={expandedFolders}
|
||||||
onOpen={openFolderNode}
|
onOpen={openFolderNode}
|
||||||
onToggle={toggleFolderNode}
|
onToggle={toggleFolderNode}
|
||||||
disabled={busy}
|
disabled={shellBusy}
|
||||||
renderNodeContent={(node) => (
|
renderNodeContent={(node) => {
|
||||||
|
const flagText = folderFlagLabel(node.flags);
|
||||||
|
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null;
|
||||||
|
return (
|
||||||
<span className="mailbox-tree-node-label">
|
<span className="mailbox-tree-node-label">
|
||||||
|
<span className="mailbox-tree-node-main">
|
||||||
<span>{node.label}</span>
|
<span>{node.label}</span>
|
||||||
{node.flags.length > 0 && <small>{node.flags.join(" ")}</small>}
|
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{flagText && <small>{flagText}</small>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -197,16 +261,27 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
<div className="file-list-sticky">
|
<div className="file-list-sticky">
|
||||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
|
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
|
||||||
<label className="mailbox-profile-field">
|
<label className="mailbox-profile-field">
|
||||||
<span>Profile</span>
|
<span>IMAP profile</span>
|
||||||
<select value={selectedProfileId} disabled={busy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
<select value={selectedProfileId} disabled={shellBusy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||||
|
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
|
||||||
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
|
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
|
||||||
<Button onClick={() => void loadProfiles()} disabled={busy}>
|
<div className="mailbox-toolbar-actions">
|
||||||
|
<Button onClick={() => void loadProfiles()} disabled={shellBusy} title="Reload IMAP profiles">
|
||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
Reload
|
Profiles
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || shellBusy} title="Refresh mailbox folders">
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
Folders
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || shellBusy} title="Refresh messages in the current folder">
|
||||||
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
|
Messages
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
|
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
|
||||||
@@ -215,9 +290,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="file-list-meta">
|
<div className="file-list-meta">
|
||||||
<span>{messages.length} message{messages.length === 1 ? "" : "s"}</span>
|
<span>{messageCountLabel}</span>
|
||||||
<span>{selectedFolder}</span>
|
<span>{selectedFolder}</span>
|
||||||
{busy && <span>Working...</span>}
|
{shellBusy && <span>Working...</span>}
|
||||||
|
{loadingMessage && <span>Loading preview...</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="file-list-table-head mailbox-message-head" role="row">
|
<div className="file-list-table-head mailbox-message-head" role="row">
|
||||||
@@ -229,13 +305,15 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
|
|
||||||
<div className="file-list-drop-target mailbox-message-scroll">
|
<div className="file-list-drop-target mailbox-message-scroll">
|
||||||
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
|
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
|
||||||
{messages.length === 0 && <div className="file-list-empty">No messages in this folder.</div>}
|
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>}
|
||||||
{messages.map((message) => {
|
{messages.map((message) => {
|
||||||
const selected = message.uid === selectedMessage?.uid;
|
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||||
|
const selected = key === selectedMessageKey;
|
||||||
|
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.uid}
|
key={message.uid}
|
||||||
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""}`}
|
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||||
role="row"
|
role="row"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => void openMessage(message)}
|
onClick={() => void openMessage(message)}
|
||||||
@@ -287,12 +365,12 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
contentType: attachment.content_type,
|
contentType: attachment.content_type,
|
||||||
sizeBytes: attachment.size_bytes
|
sizeBytes: attachment.size_bytes
|
||||||
}))}
|
}))}
|
||||||
emptyText="Select a message to inspect its content."
|
emptyText={previewEmptyText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{busy && (
|
{shellBusy && (
|
||||||
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
||||||
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
|
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -372,6 +450,48 @@ function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents:
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function mailboxMessageKey(folder: string, uid: string): string {
|
||||||
|
return `${folder || "INBOX"}::${uid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
|
||||||
|
if (!ready) return "No folder loaded";
|
||||||
|
if (loading) return "Loading messages";
|
||||||
|
if (totalCount !== null && totalCount > visibleCount) return `${visibleCount} of ${totalCount} messages`;
|
||||||
|
const count = totalCount ?? visibleCount;
|
||||||
|
return `${count} message${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderFlagLabel(flags: string[]): string {
|
||||||
|
const labels = flags.map(displayFolderFlag).filter((label): label is string => Boolean(label));
|
||||||
|
return Array.from(new Set(labels)).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayFolderFlag(flag: string): string | null {
|
||||||
|
const normalized = flag.replace(/^\\/, "").toLowerCase();
|
||||||
|
switch (normalized) {
|
||||||
|
case "sent":
|
||||||
|
return "Sent";
|
||||||
|
case "drafts":
|
||||||
|
return "Drafts";
|
||||||
|
case "trash":
|
||||||
|
return "Trash";
|
||||||
|
case "archive":
|
||||||
|
return "Archive";
|
||||||
|
case "junk":
|
||||||
|
return "Junk";
|
||||||
|
case "flagged":
|
||||||
|
return "Flagged";
|
||||||
|
case "haschildren":
|
||||||
|
case "hasnochildren":
|
||||||
|
case "noselect":
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function transportLabel(profile: MailServerProfile): string {
|
function transportLabel(profile: MailServerProfile): string {
|
||||||
const imap = profile.imap;
|
const imap = profile.imap;
|
||||||
if (!imap?.host) return "IMAP not configured";
|
if (!imap?.host) return "IMAP not configured";
|
||||||
|
|||||||
@@ -463,6 +463,31 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-tree-node-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-tree-node-main > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-folder-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 20px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-toolbar.file-manager-toolbar {
|
.mailbox-toolbar.file-manager-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
|
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
|
||||||
@@ -490,6 +515,18 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar-actions .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-breadcrumb-static {
|
.mailbox-breadcrumb-static {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
@@ -515,6 +552,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-message-row.is-loading-message {
|
||||||
|
opacity: .72;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-message-row .file-list-name strong {
|
.mailbox-message-row .file-list-name strong {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -570,6 +611,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-toolbar-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-message-head {
|
.mailbox-message-head {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user