467 lines
22 KiB
TypeScript
467 lines
22 KiB
TypeScript
import { useState } from "react";
|
|
import Button from "../Button";
|
|
import DismissibleAlert from "../DismissibleAlert";
|
|
import FormField from "../FormField";
|
|
import PasswordField from "../PasswordField";
|
|
import ToggleSwitch from "../ToggleSwitch";
|
|
|
|
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 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;
|
|
folderLookupLabel?: string;
|
|
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
|
onTestSmtp?: () => void;
|
|
onTestImap?: () => void;
|
|
onLookupFolders?: () => void;
|
|
smtpTestResult?: MailServerConnectionTestResult | null;
|
|
imapTestResult?: MailServerConnectionTestResult | null;
|
|
folderLookupResult?: MailServerFolderLookupResult | null;
|
|
onUseDetectedFolder?: () => void;
|
|
useDetectedFolderDisabled?: boolean;
|
|
mockToggle?: {
|
|
label?: string;
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
};
|
|
append?: {
|
|
enabled: boolean;
|
|
folder: string;
|
|
disabled?: boolean;
|
|
folderDisabled?: boolean;
|
|
onEnabledChange: (enabled: boolean) => void;
|
|
onFolderChange: (folder: string) => void;
|
|
};
|
|
disabled?: boolean;
|
|
className?: string;
|
|
floatingResults?: boolean;
|
|
initialSection?: MailServerSettingsSection;
|
|
};
|
|
|
|
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
|
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
|
const securityOptions = mailServerSecurityOptions;
|
|
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
|
|
|
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 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 = "Test SMTP",
|
|
imapTestLabel = "Test IMAP",
|
|
folderLookupLabel = "Folders...",
|
|
busyAction = null,
|
|
onTestSmtp,
|
|
onTestImap,
|
|
onLookupFolders,
|
|
smtpTestResult = null,
|
|
imapTestResult = null,
|
|
folderLookupResult = null,
|
|
onUseDetectedFolder,
|
|
useDetectedFolderDisabled = false,
|
|
mockToggle,
|
|
append,
|
|
disabled = false,
|
|
className = "",
|
|
floatingResults = false,
|
|
initialSection = "smtp"
|
|
}: 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 appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
|
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
|
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
|
const appendTargetHelp = append
|
|
? "Folder for sent-message copies. Leave as auto unless this campaign needs a different target."
|
|
: "Folder used when this IMAP account is used for sent-message copies. Leave as auto to use the server default.";
|
|
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
|
|
const sections: { id: MailServerSettingsSection; label: string }[] = [
|
|
{ id: "smtp", label: "SMTP" },
|
|
{ id: "imap", label: "IMAP" },
|
|
{ id: "advanced", label: "Advanced" }
|
|
];
|
|
|
|
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()}>
|
|
<div className="mail-server-segmented-control" role="tablist" aria-label="Mail server settings sections">
|
|
{sections.map((section) => (
|
|
<button
|
|
type="button"
|
|
key={section.id}
|
|
role="tab"
|
|
aria-selected={activeSection === section.id}
|
|
className={`mail-server-segmented-tab${activeSection === section.id ? " is-active" : ""}`}
|
|
onClick={() => setActiveSection(section.id)}
|
|
>
|
|
{section.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mail-server-settings-view">
|
|
{activeSection === "smtp" && (
|
|
<section className="mail-server-subsection" role="tabpanel" aria-label="SMTP settings">
|
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
|
<div className="mail-server-field-heading">Server</div>
|
|
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
|
<FormField label="Port"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
|
<FormField label="Security"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
|
<div className="mail-server-field-heading">Credentials</div>
|
|
<FormField label="Username"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
|
|
<FormField label="Password">
|
|
<PasswordField
|
|
value={stringValue(smtpCredentialValues.password)}
|
|
disabled={smtpCredentialFieldsDisabled}
|
|
saved={smtpPasswordSaved}
|
|
savedPlaceholder={smtpSavedPasswordPlaceholder}
|
|
autoComplete="new-password"
|
|
onValueChange={(password) => patchSmtpCredentials({ password })}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
{onTestSmtp && (
|
|
<div className="button-row compact-actions mail-server-actions">
|
|
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
|
|
</div>
|
|
)}
|
|
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
|
|
</section>
|
|
)}
|
|
|
|
{activeSection === "imap" && (
|
|
<section className="mail-server-subsection" role="tabpanel" aria-label="IMAP settings">
|
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
|
<div className="mail-server-field-heading">Server</div>
|
|
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
|
<FormField label="Port"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
|
<FormField label="Security"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
|
<div className="mail-server-field-heading">Credentials</div>
|
|
<FormField label="Username"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
|
|
<FormField label="Password">
|
|
<PasswordField
|
|
value={stringValue(imapCredentialValues.password)}
|
|
disabled={imapCredentialFieldsDisabled}
|
|
saved={imapPasswordSaved}
|
|
savedPlaceholder={imapSavedPasswordPlaceholder}
|
|
autoComplete="new-password"
|
|
onValueChange={(password) => patchImapCredentials({ password })}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
{onTestImap && (
|
|
<div className="button-row compact-actions mail-server-actions">
|
|
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>
|
|
</div>
|
|
)}
|
|
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
|
</section>
|
|
)}
|
|
|
|
{activeSection === "advanced" && (
|
|
<section className="mail-server-subsection" role="tabpanel" aria-label="Advanced mail settings">
|
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
|
{mockToggle && (
|
|
<div className="mail-server-field-span mail-server-toggle-row">
|
|
<ToggleSwitch
|
|
label={mockToggle.label ?? "Mock server settings"}
|
|
checked={mockToggle.checked}
|
|
disabled={disabled || mockToggle.disabled}
|
|
onChange={mockToggle.onChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
<FormField label="SMTP timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
|
<FormField label="IMAP timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
|
{append && (
|
|
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
|
<ToggleSwitch label="Append successfully sent messages to Sent" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
|
</div>
|
|
)}
|
|
<FormField label="Append target folder" help={appendTargetHelp}>
|
|
<div className="field-with-action mail-server-folder-field">
|
|
<input
|
|
value={appendTargetFolder}
|
|
disabled={appendTargetDisabled}
|
|
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
|
placeholder="auto"
|
|
/>
|
|
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
|
|
</div>
|
|
</FormField>
|
|
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
|
</div>
|
|
</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> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
|
|
)}
|
|
</DismissibleAlert>
|
|
);
|
|
}
|
|
|
|
export function MailServerFolderLookupResultView({
|
|
result,
|
|
disabled = false,
|
|
onUseDetected
|
|
}: {
|
|
result: MailServerFolderLookupResult | null | undefined;
|
|
disabled?: boolean;
|
|
onUseDetected?: () => void;
|
|
}) {
|
|
if (!result) return null;
|
|
if (!result.ok) {
|
|
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
|
}
|
|
|
|
const folders = result.folders ?? [];
|
|
return (
|
|
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
|
<p>{result.message}</p>
|
|
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "-"}</strong></p>
|
|
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>Use detected folder</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);
|
|
}
|