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.
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 {})
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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MailProfilePolicy>(blankPolicy);
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 [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<MailProfilePolicy>) {
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
@@ -438,31 +460,34 @@ export function MailProfilePolicyEditor({
<div className="mail-profile-checkbox-grid">
{candidateProfiles.map((profile) => (
<label className="mail-profile-checkbox" key={profile.id}>
<input type="checkbox" checked={selectedProfileIds.has(profile.id)} disabled={disabled} 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>
</label>
))}
{candidateProfiles.length === 0 && <p className="muted small-note">No profiles are visible for this policy scope.</p>}
</div>
{parentAllowedProfileIds && <p className="muted small-note">An ancestor allow-list limits selectable profiles to {parentAllowedProfileIds.size} profile(s).</p>}
</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">
<PolicyFlagSelect label="User profiles" value={booleanToFlag(policy.allow_user_profiles)} disabled={disabled} 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="Campaign profiles" value={booleanToFlag(policy.allow_campaign_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_campaign_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} allowDisabled={parentBlocksGroupProfiles} onChange={(value) => setFlag("allow_group_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>
{blockedProfileDefinitions && <PolicyLockedHint>Explicit allow is unavailable for {blockedProfileDefinitions} because an ancestor policy blocks those definitions.</PolicyLockedHint>}
</section>
<section className="mail-policy-section">
<h3>Credential inheritance</h3>
<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) })} />
<CredentialOverrideSelect label="SMTP override" value={credentialOverrideToValue(policy.smtp_credentials?.allow_override)} disabled={disabled} 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) })} />
<CredentialOverrideSelect label="IMAP override" value={credentialOverrideToValue(policy.imap_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(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 || smtpCredentialLocked} onChange={(value) => setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(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 || imapCredentialLocked} onChange={(value) => setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} />
</div>
{lockedCredentialKinds && <PolicyLockedHint>{lockedCredentialKinds} credential inheritance is locked by an ancestor policy.</PolicyLockedHint>}
</section>
<section className="mail-policy-section">
@@ -480,17 +505,19 @@ export function MailProfilePolicyEditor({
</section>
{effectivePolicy && (
<section className="mail-policy-section mail-policy-effective">
<h3>Effective campaign policy</h3>
<div className="mail-policy-effective-grid">
<div><span>Profiles</span><strong>{effectiveProfileLabel(effectivePolicy.allowed_profile_ids)}</strong></div>
<div><span>User profiles</span><strong>{effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"}</strong></div>
<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>
<div><span>SMTP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.smtp_credentials)}</strong></div>
<div><span>IMAP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.imap_credentials)}</strong></div>
</div>
</section>
<EffectivePolicyBlock
title="Effective mail policy"
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType)}
className="mail-policy-section mail-policy-effective"
gridClassName="mail-policy-effective-grid"
>
<EffectivePolicyValue label="Profiles" value={effectiveProfileLabel(effectivePolicy.allowed_profile_ids)} />
<EffectivePolicyValue label="User profiles" value={effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"} />
<EffectivePolicyValue label="Group profiles" value={effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"} />
<EffectivePolicyValue label="Campaign-local" value={effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"} />
<EffectivePolicyValue label="SMTP credentials" value={credentialPolicyLabel(effectivePolicy.smtp_credentials)} />
<EffectivePolicyValue label="IMAP credentials" value={credentialPolicyLabel(effectivePolicy.imap_credentials)} />
</EffectivePolicyBlock>
)}
</div>
</Card>
@@ -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 (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
<option value="inherit">Inherit</option>
<option value="allow">Explicit allow</option>
<option value="allow" disabled={allowDisabled}>Explicit allow</option>
<option value="deny">Deny</option>
</select>
</FormField>
@@ -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";

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 {
Button,
@@ -33,25 +33,38 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
const [selectedFolder, setSelectedFolder] = useState("INBOX");
const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(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 }) {
<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>}
<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">
<div className="file-tree-heading">Folders</div>
<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
nodes={folderTree}
getNodeId={(node) => node.id}
@@ -182,13 +239,20 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
expandedIds={expandedFolders}
onOpen={openFolderNode}
onToggle={toggleFolderNode}
disabled={busy}
renderNodeContent={(node) => (
<span className="mailbox-tree-node-label">
<span>{node.label}</span>
{node.flags.length > 0 && <small>{node.flags.join(" ")}</small>}
</span>
)}
disabled={shellBusy}
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-main">
<span>{node.label}</span>
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>}
</span>
{flagText && <small>{flagText}</small>}
</span>
);
}}
/>
</div>
</aside>
@@ -197,16 +261,27 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-list-sticky">
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
<label className="mailbox-profile-field">
<span>Profile</span>
<select value={selectedProfileId} disabled={busy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
<span>IMAP profile</span>
<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>)}
</select>
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
<Button onClick={() => void loadProfiles()} disabled={busy}>
<RefreshCw size={16} aria-hidden="true" />
Reload
</Button>
<div className="mailbox-toolbar-actions">
<Button onClick={() => void loadProfiles()} disabled={shellBusy} title="Reload IMAP profiles">
<RefreshCw size={16} aria-hidden="true" />
Profiles
</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>
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
@@ -215,9 +290,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
</nav>
<div className="file-list-meta">
<span>{messages.length} message{messages.length === 1 ? "" : "s"}</span>
<span>{messageCountLabel}</span>
<span>{selectedFolder}</span>
{busy && <span>Working...</span>}
{shellBusy && <span>Working...</span>}
{loadingMessage && <span>Loading preview...</span>}
</div>
<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-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) => {
const selected = message.uid === selectedMessage?.uid;
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey;
return (
<div
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"
tabIndex={0}
onClick={() => 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}
/>
</div>
</section>
{busy && (
{shellBusy && (
<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>
@@ -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";

View File

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