Prepare v0.1.0 release

This commit is contained in:
2026-06-24 20:00:38 +02:00
parent a9d16a2c89
commit 97015e5a01
10 changed files with 409 additions and 79 deletions

View File

@@ -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
View 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"
}
}

View File

@@ -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,

View File

@@ -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:

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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;
} }