Prepare v0.1.0 release
This commit is contained in:
@@ -49,3 +49,7 @@ Frontend package:
|
||||
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
|
||||
|
||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||
|
||||
## Release packaging
|
||||
|
||||
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/mail-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths.
|
||||
|
||||
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
"module": "webui/src/index.ts",
|
||||
"types": "webui/src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./webui/src/index.ts",
|
||||
"import": "./webui/src/index.ts"
|
||||
},
|
||||
"./styles/mail-profiles.css": "./webui/src/styles/mail-profiles.css"
|
||||
},
|
||||
"files": [
|
||||
"webui/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.0",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,40 @@ def _policy_from_attr(value: Any) -> dict[str, Any]:
|
||||
return normalize_mail_profile_policy(value if isinstance(value, dict) else {})
|
||||
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user