diff --git a/README.md b/README.md index a498470..e120994 100644 --- a/README.md +++ b/README.md @@ -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. 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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..c84cc68 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index 3d7fbcd..0429c67 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -198,6 +198,40 @@ def _policy_from_attr(value: Any) -> dict[str, Any]: 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: inherit = data.get("inherit") if isinstance(inherit, bool): @@ -216,9 +250,9 @@ def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, 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) - 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 []) if allowed_ids: policy.allowed_profile_id_sets.append(set(allowed_ids)) @@ -280,18 +314,18 @@ def effective_mail_profile_policy( policy = EffectiveMailProfilePolicy() system_settings = get_system_settings(session) - _merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system") - _merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant") + _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", source_id=tenant.id, label="Tenant") if owner_user_id: user = session.get(User, owner_user_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: group = session.get(Group, owner_group_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: - _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 @@ -309,11 +343,11 @@ def parent_mail_profile_policy( policy = EffectiveMailProfilePolicy() 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": 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"}: return policy @@ -325,11 +359,11 @@ def parent_mail_profile_policy( if campaign.owner_user_id: user = session.get(User, campaign.owner_user_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: group = session.get(Group, campaign.owner_group_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 @@ -454,7 +488,7 @@ def _ensure_scope_allows_profile_creation( if scope_type == "campaign": policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id) 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 if scope_type == "user": 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") +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( session: Session, *, @@ -737,6 +782,12 @@ def assert_campaign_mail_policy_allows_json( return 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 + 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( session, tenant_id=tenant_id, diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index 7f69f9c..6c13c47 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -30,6 +30,7 @@ from govoplan_mail.backend.mail_profiles import ( get_mail_server_profile, imap_config_from_profile, list_mail_server_profiles, + parent_mail_profile_policy, profile_response_payload, set_mail_profile_policy, smtp_config_from_profile, @@ -138,6 +139,12 @@ def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: st 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) def list_profiles( include_inactive: bool = False, @@ -267,11 +274,19 @@ def read_mail_profile_policy( try: policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) effective = None + effective_sources = [] 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: - effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict() - return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective) + effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id) + 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: 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() effective = None + effective_sources = [] if scope_type == "campaign" and scope_id: - effective = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id).as_dict() - return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective) + effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id) + 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: session.rollback() 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, port=result.port, security=result.security, + total_count=result.total_count, messages=[_mailbox_summary_response(message) for message in result.messages], ) except MailProfileError as exc: diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index 534516b..52b83b6 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -47,11 +47,21 @@ class MailProfilePolicyUpdateRequest(BaseModel): 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): scope_type: MailProfileScope scope_id: str | None = None policy: dict[str, Any] 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): @@ -160,6 +170,7 @@ class MailMailboxMessageListResponse(BaseModel): host: str | None = None port: int | None = None security: str | None = None + total_count: int = 0 messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py index d16dc5d..e74e6b8 100644 --- a/src/govoplan_mail/backend/sending/imap.py +++ b/src/govoplan_mail/backend/sending/imap.py @@ -110,6 +110,7 @@ class ImapMailboxMessageListResult: security: str folder: str messages: list[ImapMailboxMessageSummary] + total_count: int @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] ] - 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) try: @@ -526,7 +527,7 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: for uid in reversed(uids[-limit:]): 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)) - 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: try: client.logout() diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 7189e2c..524d7ed 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -80,6 +80,7 @@ export type MailMailboxMessageListResponse = { host?: string | null; port?: number | null; security?: MailSecurity | string | null; + total_count?: number; messages: MailMailboxMessageSummary[]; }; @@ -138,11 +139,21 @@ export type MailProfilePolicy = { blacklist?: MailProfilePatternRules | null; }; +export type PolicySourceStep = { + scope_type: string; + scope_id?: string | null; + label: string; + applied_fields?: string[]; +}; + export type MailProfilePolicyResponse = { scope_type: MailProfileScope; scope_id?: string | null; policy: MailProfilePolicy; effective_policy?: MailProfilePolicy | null; + parent_policy?: MailProfilePolicy | null; + effective_policy_sources?: PolicySourceStep[]; + parent_policy_sources?: PolicySourceStep[]; }; export type MailServerProfilePayload = { diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index e66b03f..3e9822c 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -1,5 +1,5 @@ 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 type { ApiSettings } from "../../types"; import { @@ -329,6 +329,8 @@ export function MailProfilePolicyEditor({ }: MailProfilePolicyEditorProps) { const [policy, setPolicy] = useState(blankPolicy); const [effectivePolicy, setEffectivePolicy] = useState(null); + const [parentPolicy, setParentPolicy] = useState(null); + const [effectivePolicySources, setEffectivePolicySources] = useState([]); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -345,6 +347,8 @@ export function MailProfilePolicyEditor({ if (!scopeReady) { setPolicy(blankPolicy); setEffectivePolicy(null); + setParentPolicy(null); + setEffectivePolicySources([]); return; } setLoading(true); @@ -352,9 +356,13 @@ export function MailProfilePolicyEditor({ const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId); setPolicy(normalizePolicy(response.policy)); 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) { setPolicy(blankPolicy); setEffectivePolicy(null); + setParentPolicy(null); + setEffectivePolicySources([]); setError(errorMessage(err)); } finally { setLoading(false); @@ -370,6 +378,8 @@ export function MailProfilePolicyEditor({ const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicy(policy), scopeId); setPolicy(normalizePolicy(response.policy)); 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."); await onSaved?.(); } catch (err) { @@ -385,6 +395,18 @@ export function MailProfilePolicyEditor({ ); const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []); 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) { setPolicy((current) => normalizePolicy({ ...current, ...patch })); @@ -438,31 +460,34 @@ export function MailProfilePolicyEditor({
{candidateProfiles.map((profile) => ( ))} {candidateProfiles.length === 0 &&

No profiles are visible for this policy scope.

}
+ {parentAllowedProfileIds &&

An ancestor allow-list limits selectable profiles to {parentAllowedProfileIds.size} profile(s).

}
-

Lower-level profile definitions

+

Lower-level mail definitions

- setFlag("allow_user_profiles", value)} /> - setFlag("allow_group_profiles", value)} /> - setFlag("allow_campaign_profiles", value)} /> + setFlag("allow_user_profiles", value)} /> + setFlag("allow_group_profiles", value)} /> + setFlag("allow_campaign_profiles", value)} />
+ {blockedProfileDefinitions && Explicit allow is unavailable for {blockedProfileDefinitions} because an ancestor policy blocks those definitions.}

Credential inheritance

- setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} /> - setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} /> - setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} /> - setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} /> + setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} /> + setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} /> + setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} /> + setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} />
+ {lockedCredentialKinds && {lockedCredentialKinds} credential inheritance is locked by an ancestor policy.}
@@ -480,17 +505,19 @@ export function MailProfilePolicyEditor({
{effectivePolicy && ( -
-

Effective campaign policy

-
-
Profiles{effectiveProfileLabel(effectivePolicy.allowed_profile_ids)}
-
User profiles{effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"}
-
Group profiles{effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"}
-
Campaign profiles{effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"}
-
SMTP credentials{credentialPolicyLabel(effectivePolicy.smtp_credentials)}
-
IMAP credentials{credentialPolicyLabel(effectivePolicy.imap_credentials)}
-
-
+ 0 ? effectivePolicySources : mailPolicySourcePath(scopeType)} + className="mail-policy-section mail-policy-effective" + gridClassName="mail-policy-effective-grid" + > + + + + + + + )} @@ -626,9 +653,11 @@ function ProfileForm({ onImapEnabledChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })} smtpDisabled={disabled} smtpCredentialDisabled={credentialDisabled} + smtpPasswordSaved={Boolean(existingProfile?.smtp_password_configured)} imapToggleDisabled={imapToggleDisabled} imapServerDisabled={disabled || !draft.imapEnabled} imapCredentialDisabled={credentialDisabled || !draft.imapEnabled} + imapPasswordSaved={Boolean(existingProfile?.imap_password_configured)} imapActionDisabled={disabled || !draft.imapEnabled} smtpTitle="SMTP" 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 ( @@ -916,6 +945,14 @@ function effectiveProfileLabel(value: string[] | null | undefined): string { 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 { if (!transport) return "Not configured"; const host = transport.host || "No host"; diff --git a/webui/src/features/mail/MailboxPage.tsx b/webui/src/features/mail/MailboxPage.tsx index b69c013..7c29412 100644 --- a/webui/src/features/mail/MailboxPage.tsx +++ b/webui/src/features/mail/MailboxPage.tsx @@ -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 { Button, @@ -33,25 +33,38 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) { const [selectedProfileId, setSelectedProfileId] = useState(""); const [folders, setFolders] = useState([]); const [selectedFolder, setSelectedFolder] = useState("INBOX"); + const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState(""); const [expandedFolders, setExpandedFolders] = useState>(() => new Set()); const [messages, setMessages] = useState([]); + const [messageTotalCount, setMessageTotalCount] = useState(null); const [selectedMessage, setSelectedMessage] = useState(null); + const [pendingMessageKey, setPendingMessageKey] = useState(""); const [loadingProfiles, setLoadingProfiles] = useState(true); const [loadingFolders, setLoadingFolders] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessage, setLoadingMessage] = useState(false); 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 imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]); const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]); 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..."; useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); 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(() => { 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 ?? ""); if (usable.length === 0) { setFolders([]); + setFoldersLoadedForProfile(""); setMessages([]); + setMessageTotalCount(null); setSelectedMessage(null); + setPendingMessageKey(""); } } catch (err) { setError(errorText(err)); @@ -85,71 +101,112 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) { async function loadFolders(profileId = selectedProfileId) { if (!profileId) return; + const requestId = ++folderRequestRef.current; + messageListRequestRef.current += 1; + messageDetailRequestRef.current += 1; setLoadingFolders(true); + setFoldersLoadedForProfile(""); + setMessageTotalCount(null); + setPendingMessageKey(""); setError(""); try { 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."); const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }]; setFolders(loaded); setExpandedFolders(new Set()); - setSelectedFolder((current) => { - if (current && loaded.some((folder) => folder.name === current)) return current; - if (loaded.some((folder) => folder.name === "INBOX")) return "INBOX"; - return response.detected_sent_folder || loaded[0]?.name || "INBOX"; - }); + let nextFolder = selectedFolder; + if (!nextFolder || !loaded.some((folder) => folder.name === nextFolder)) { + nextFolder = loaded.some((folder) => folder.name === "INBOX") ? "INBOX" : response.detected_sent_folder || loaded[0]?.name || "INBOX"; + } + setSelectedFolder(nextFolder); + setFoldersLoadedForProfile(profileId); } catch (err) { + if (requestId !== folderRequestRef.current) return; setError(errorText(err)); setFolders([]); + setFoldersLoadedForProfile(""); setMessages([]); + setMessageTotalCount(null); setSelectedMessage(null); + setPendingMessageKey(""); } finally { - setLoadingFolders(false); + if (requestId === folderRequestRef.current) setLoadingFolders(false); } } async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) { if (!profileId || !folder) return; + const requestId = ++messageListRequestRef.current; + messageDetailRequestRef.current += 1; setLoadingMessages(true); + setMessageTotalCount(null); + setSelectedMessage(null); + setPendingMessageKey(""); setError(""); try { 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); } catch (err) { + if (requestId !== messageListRequestRef.current) return; setError(errorText(err)); setMessages([]); + setMessageTotalCount(null); setSelectedMessage(null); + setPendingMessageKey(""); } finally { - setLoadingMessages(false); + if (requestId === messageListRequestRef.current) setLoadingMessages(false); } } 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); setError(""); 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); + setPendingMessageKey(""); } catch (err) { + if (requestId !== messageDetailRequestRef.current) return; setError(errorText(err)); + setPendingMessageKey(""); } finally { - setLoadingMessage(false); + if (requestId === messageDetailRequestRef.current) setLoadingMessage(false); } } function selectProfile(profileId: string) { + folderRequestRef.current += 1; + messageListRequestRef.current += 1; + messageDetailRequestRef.current += 1; setSelectedProfileId(profileId); setFolders([]); + setFoldersLoadedForProfile(""); setMessages([]); + setMessageTotalCount(null); setSelectedMessage(null); + setPendingMessageKey(""); setExpandedFolders(new Set()); + setLoadingFolders(false); + setLoadingMessages(false); + setLoadingMessage(false); } function openFolderNode(node: MailFolderNode) { if (node.folderName) { - setSelectedFolder(node.folderName); + if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName); + else setSelectedFolder(node.folderName); return; } toggleFolderNode(node); @@ -168,11 +225,11 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{error && {error}} -
+
@@ -197,16 +261,27 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"} - +
+ + + +
- {messages.length} message{messages.length === 1 ? "" : "s"} + {messageCountLabel} {selectedFolder} - {busy && Working...} + {shellBusy && Working...} + {loadingMessage && Loading preview...}
@@ -229,13 +305,15 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
- {messages.length === 0 &&
No messages in this folder.
} + {messages.length === 0 &&
{messageEmptyText}
} {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 (
void openMessage(message)} @@ -287,12 +365,12 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) { contentType: attachment.content_type, sizeBytes: attachment.size_bytes }))} - emptyText="Select a message to inspect its content." + emptyText={previewEmptyText} />
- {busy && ( + {shellBusy && (
{loadingLabel}
@@ -372,6 +450,48 @@ function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: 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 { const imap = profile.imap; if (!imap?.host) return "IMAP not configured"; diff --git a/webui/src/styles/mail-profiles.css b/webui/src/styles/mail-profiles.css index 9b8d977..eaf768f 100644 --- a/webui/src/styles/mail-profiles.css +++ b/webui/src/styles/mail-profiles.css @@ -463,6 +463,31 @@ 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 { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto; @@ -490,6 +515,18 @@ 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 { cursor: default; } @@ -515,6 +552,10 @@ cursor: pointer; } +.mailbox-message-row.is-loading-message { + opacity: .72; +} + .mailbox-message-row .file-list-name strong { max-width: 100%; } @@ -570,6 +611,10 @@ grid-template-columns: 1fr; } + .mailbox-toolbar-actions { + justify-content: flex-start; + } + .mailbox-message-head { display: none; }