Files
govoplan-core/webui/src/components/mail/MailServerSettingsPanel.tsx
T

433 lines
20 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import SegmentedControl from "../SegmentedControl";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
export type MailServerCredentialSettings = {
username?: string | null;
password?: string | null;
};
export type MailServerSmtpSettings = {
host?: string | null;
port?: string | number | null;
security?: MailServerSecurity | null;
timeout_seconds?: string | number | null;
username?: string | null;
password?: string | null;
};
export type MailServerImapSettings = MailServerSmtpSettings & {
sent_folder?: string | null;
};
export type MailServerConnectionTestResult = {
ok: boolean;
protocol?: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailServerSecurity | null;
message: string;
details?: Record<string, unknown> | null;
};
export type MailServerFolderLookupResult = {
ok: boolean;
protocol?: "imap";
host?: string | null;
port?: number | null;
security?: MailServerSecurity | null;
message: string;
detected_sent_folder?: string | null;
folders?: {name: string;flags?: string[];}[];
details?: Record<string, unknown> | null;
};
export type MailServerSettingsSection = "smtp" | "imap";
export type MailServerSettingsMode = "all" | "server" | "credentials";
export type MailServerSettingsPanelProps = {
smtp: MailServerSmtpSettings;
imap: MailServerImapSettings;
onSmtpChange: (patch: Partial<MailServerSmtpSettings>) => void;
onImapChange: (patch: Partial<MailServerImapSettings>) => void;
smtpCredentials?: MailServerCredentialSettings;
imapCredentials?: MailServerCredentialSettings;
onSmtpCredentialsChange?: (patch: Partial<MailServerCredentialSettings>) => void;
onImapCredentialsChange?: (patch: Partial<MailServerCredentialSettings>) => void;
smtpDisabled?: boolean;
smtpCredentialDisabled?: boolean;
smtpPasswordSaved?: boolean;
smtpSavedPasswordPlaceholder?: string;
smtpActionDisabled?: boolean;
imapServerDisabled?: boolean;
imapCredentialDisabled?: boolean;
imapPasswordSaved?: boolean;
imapSavedPasswordPlaceholder?: string;
imapActionDisabled?: boolean;
smtpTestLabel?: string;
imapTestLabel?: string;
busyAction?: "smtp" | "imap" | "folders" | string | null;
onTestSmtp?: () => void;
onTestImap?: () => void;
smtpTestResult?: MailServerConnectionTestResult | null;
imapTestResult?: MailServerConnectionTestResult | null;
disabled?: boolean;
className?: string;
floatingResults?: boolean;
initialSection?: MailServerSettingsSection;
visibleSections?: readonly MailServerSettingsSection[];
mode?: MailServerSettingsMode;
};
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
const securityOptions = mailServerSecurityOptions;
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
if (security === "tls") return 465;
if (security === "plain") return 25;
return 587;
}
export function defaultImapPort(security: MailServerSecurity | null | undefined): number {
return security === "tls" ? 993 : 143;
}
export function mailTextOrNull(value: string | number | null | undefined, trim = true): string | null {
const text = value === null || value === undefined ? "" : String(value);
const normalized = trim ? text.trim() : text;
return normalized ? normalized : null;
}
export function mailNumberOrNull(value: string | number | null | undefined): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
const text = String(value ?? "").trim();
if (!text) return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
export function mailNumberOrDefault(value: string | number | null | undefined, fallback: number): number {
return mailNumberOrNull(value) ?? fallback;
}
export function normalizeMailServerSecurity<TSecurity extends string = MailServerSecurityOption>(
value: string | null | undefined,
options: {fallback: TSecurity;allowedSecurity?: readonly TSecurity[];})
: TSecurity {
const allowed = options.allowedSecurity ?? mailServerSecurityOptions as unknown as readonly TSecurity[];
return allowed.includes(value as TSecurity) ? value as TSecurity : options.fallback;
}
export function mailTransportCredentialsPayload(
username: string | number | null | undefined,
password: string | number | null | undefined,
preserveBlankPassword: boolean)
: {username?: string | null;password?: string | null;} {
const payload: {username?: string | null;password?: string | null;} = { username: mailTextOrNull(username) };
if (String(password ?? "") || !preserveBlankPassword) payload.password = mailTextOrNull(password, false);
return payload;
}
function mailRecordText(record: Record<string, unknown>, key: string, fallback = ""): string {
const value = record[key];
if (value === null || value === undefined) return fallback;
return String(value);
}
export function mailTransportCredentialsPayloadFromRecords(
primary: Record<string, unknown>,
legacy: Record<string, unknown>,
preserveBlankPassword: boolean)
: {username?: string | null;password?: string | null;} {
return mailTransportCredentialsPayload(
mailRecordText(primary, "username", mailRecordText(legacy, "username")),
mailRecordText(primary, "password", mailRecordText(legacy, "password")),
preserveBlankPassword
);
}
export function mailSmtpSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
settings: MailServerSmtpSettings,
options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fallbackTimeoutSeconds?: number;})
: {host: string | null;port: number | null;security: TSecurity;timeout_seconds: number;} {
return {
host: mailTextOrNull(settings.host),
port: mailNumberOrNull(settings.port),
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
};
}
export function mailImapSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
settings: MailServerImapSettings,
options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fallbackTimeoutSeconds?: number;})
: {host: string | null;port: number | null;security: TSecurity;sent_folder: string;timeout_seconds: number;} {
return {
host: mailTextOrNull(settings.host),
port: mailNumberOrNull(settings.port),
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
sent_folder: mailTextOrNull(settings.sent_folder) || "auto",
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
};
}
export function hasMailImapSettings(values: Array<string | number | null | undefined>): boolean {
return values.some((value) => String(value ?? "").trim() !== "");
}
export function resolveMailServerSettingsActiveSection(
activeSection: MailServerSettingsSection,
initialSection: MailServerSettingsSection,
previousInitialSection: MailServerSettingsSection,
visibleSectionIds: readonly MailServerSettingsSection[])
: MailServerSettingsSection {
const fallbackSection = visibleSectionIds[0] ?? "smtp";
const initialSectionVisible = visibleSectionIds.includes(initialSection);
if (previousInitialSection !== initialSection && initialSectionVisible) return initialSection;
if (!visibleSectionIds.includes(activeSection)) return initialSectionVisible ? initialSection : fallbackSection;
return activeSection;
}
export default function MailServerSettingsPanel({
smtp,
imap,
onSmtpChange,
onImapChange,
smtpCredentials,
imapCredentials,
onSmtpCredentialsChange,
onImapCredentialsChange,
smtpDisabled = false,
smtpCredentialDisabled = smtpDisabled,
smtpPasswordSaved = false,
smtpSavedPasswordPlaceholder = "••••••••",
smtpActionDisabled = smtpDisabled,
imapServerDisabled = false,
imapCredentialDisabled = imapServerDisabled,
imapPasswordSaved = false,
imapSavedPasswordPlaceholder = "••••••••",
imapActionDisabled = imapServerDisabled,
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
busyAction = null,
onTestSmtp,
onTestImap,
smtpTestResult = null,
imapTestResult = null,
disabled = false,
className = "",
floatingResults = false,
initialSection = "smtp",
visibleSections,
mode = "all"
}: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
const smtpActionsDisabled = disabled || smtpActionDisabled;
const imapFieldsDisabled = disabled || imapServerDisabled;
const imapCredentialFieldsDisabled = disabled || imapCredentialDisabled;
const imapActionsDisabled = disabled || imapActionDisabled;
const smtpCredentialValues = smtpCredentials ?? { username: smtp.username, password: smtp.password };
const imapCredentialValues = imapCredentials ?? { username: imap.username, password: imap.password };
const smtpSecurity = stringValue(smtp.security, "starttls");
const imapSecurity = stringValue(imap.security, "tls");
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }];
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
const visibleSectionIds = sections.map((section) => section.id);
const fallbackSection = visibleSectionIds[0] ?? "smtp";
const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
const sectionKey = visibleSectionIds.join("|");
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
const previousInitialSectionRef = useRef(initialSection);
const showSectionSwitcher = sections.length > 1;
const showServerFields = mode !== "credentials";
const showCredentialFields = mode !== "server";
useEffect(() => {
const nextSection = resolveMailServerSettingsActiveSection(
activeSection,
initialSection,
previousInitialSectionRef.current,
visibleSectionIds
);
previousInitialSectionRef.current = initialSection;
if (nextSection !== activeSection) setActiveSection(nextSection);
}, [activeSection, initialSection, sectionKey]);
function patchSmtpSecurity(security: MailServerSecurity) {
const port = portForSecurityChange("smtp", smtp.port, smtpSecurity, security);
onSmtpChange(port === undefined ? { security } : { security, port });
}
function patchImapSecurity(security: MailServerSecurity) {
const port = portForSecurityChange("imap", imap.port, imapSecurity, security);
onImapChange(port === undefined ? { security } : { security, port });
}
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
if (onSmtpCredentialsChange) onSmtpCredentialsChange(patch);else
onSmtpChange(patch);
}
function patchImapCredentials(patch: Partial<MailServerCredentialSettings>) {
if (onImapCredentialsChange) onImapCredentialsChange(patch);else
onImapChange(patch);
}
return (
<div className={`mail-server-settings-panel ${className}`.trim()}>
{showSectionSwitcher &&
<SegmentedControl
className="mail-server-segmented-control"
size="equal"
ariaLabel="i18n:govoplan-core.mail_server_settings_sections.40a163b7"
value={activeSection}
onChange={setActiveSection}
options={sections}
/>
}
<div className="mail-server-settings-view">
{activeSection === "smtp" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
</>
}
{showCredentialFields &&
<CredentialFields
values={smtpCredentialValues}
onChange={patchSmtpCredentials}
disabled={smtpCredentialFieldsDisabled}
savedPassword={smtpPasswordSaved}
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
}
</div>
{onTestSmtp &&
<div className="button-row compact-actions mail-server-actions">
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "i18n:govoplan-core.testing.15ccc832" : smtpTestLabel}</Button>
</div>
}
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
</section>
}
{activeSection === "imap" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
</>
}
{showCredentialFields &&
<CredentialFields
values={imapCredentialValues}
onChange={patchImapCredentials}
disabled={imapCredentialFieldsDisabled}
savedPassword={imapPasswordSaved}
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
}
</div>
{onTestImap &&
<div className="button-row compact-actions mail-server-actions">
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "i18n:govoplan-core.testing.15ccc832" : imapTestLabel}</Button>
</div>
}
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
</section>
}
</div>
</div>);
}
export function MailServerActionResult({ result, floating = false }: {result: MailServerConnectionTestResult | null | undefined;floating?: boolean;}) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating={floating}>
{result.message}
{result.ok && typeof authenticated === "boolean" &&
<span> i18n:govoplan-core.authentication.e665f157 {authenticated ? "i18n:govoplan-core.credentials_accepted.ae8e2629" : "i18n:govoplan-core.not_used.487f8bcd"}.</span>
}
</DismissibleAlert>);
}
export function MailServerFolderLookupResultView({
result,
disabled = false,
onUseDetected,
compact = true,
floatingFailures = false
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
if (!result) return null;
if (!result.ok) {
return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
}
const folders = result.folders ?? [];
return (
<DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
{folders.length > 0 &&
<div className="mail-server-folder-chip-list">
{folders.slice(0, 12).map((folder) =>
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
)}
{folders.length > 12 && <span className="mail-server-folder-chip">+{folders.length - 12} more</span>}
</div>
}
</DismissibleAlert>);
}
function SecuritySelect({ value, disabled, onChange }: {value: string;disabled: boolean;onChange: (value: MailServerSecurity) => void;}) {
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
}
function portForSecurityChange(protocol: "smtp" | "imap", currentPort: string | number | null | undefined, currentSecurity: MailServerSecurity, nextSecurity: MailServerSecurity): number | undefined {
const current = numberValue(currentPort);
const previousDefault = protocol === "smtp" ? defaultSmtpPort(currentSecurity) : defaultImapPort(currentSecurity);
if (current !== null && current !== previousDefault) return undefined;
return protocol === "smtp" ? defaultSmtpPort(nextSecurity) : defaultImapPort(nextSecurity);
}
function numberValue(value: string | number | null | undefined): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
const trimmed = String(value ?? "").trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function stringValue(value: string | number | null | undefined, fallback = ""): string {
if (value === null || value === undefined) return fallback;
return String(value);
}