433 lines
20 KiB
TypeScript
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);
|
|
}
|