Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 15794e920e
commit 02564047e9
73 changed files with 4073 additions and 478 deletions

View File

@@ -1,19 +1,60 @@
import { useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
type CardProps = {
title?: React.ReactNode;
children: React.ReactNode;
actions?: React.ReactNode;
title?: ReactNode;
children: ReactNode;
actions?: ReactNode;
collapsible?: boolean;
collapseKey?: string;
persistCollapse?: boolean;
};
export default function Card({ title, children, actions, collapsible = false }: CardProps) {
const [collapsed, setCollapsed] = useState(false);
function resolveCollapseStorageKey(collapsible: boolean, persistCollapse: boolean, collapseKey: string | undefined, title: ReactNode): string | null {
if (!collapsible || !persistCollapse) return null;
if (collapseKey) return `govoplan.card.collapsed:${collapseKey}`;
if (typeof window === "undefined" || typeof title !== "string") return null;
const normalizedTitle = title.trim();
if (!normalizedTitle) return null;
return `govoplan.card.collapsed:${window.location.pathname}:${normalizedTitle}`;
}
function readCollapseState(storageKey: string | null): boolean {
if (!storageKey || typeof window === "undefined") return false;
try {
return window.localStorage.getItem(storageKey) === "1";
} catch {
return false;
}
}
function writeCollapseState(storageKey: string | null, collapsed: boolean): void {
if (!storageKey || typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, collapsed ? "1" : "0");
} catch {
// localStorage may be unavailable in private or restricted contexts.
}
}
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) {
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
const hasHeader = Boolean(title || actions || collapsible);
const body = <div className="card-body">{children}</div>;
const shouldRenderBody = !collapsible || !collapsed;
useEffect(() => {
setCollapseState({ storageKey, collapsed: readCollapseState(storageKey) });
}, [storageKey]);
function toggleCollapsed() {
const nextCollapsed = !collapsed;
writeCollapseState(storageKey, nextCollapsed);
setCollapseState({ storageKey, collapsed: nextCollapsed });
}
return (
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
{hasHeader && (
@@ -29,7 +70,7 @@ export default function Card({ title, children, actions, collapsible = false }:
aria-label={collapsed ? "Show content" : "Show header only"}
aria-expanded={!collapsed}
title={collapsed ? "Show content" : "Show header only"}
onClick={() => setCollapsed((value) => !value)}
onClick={toggleCollapsed}
>
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button>

View File

@@ -12,6 +12,7 @@ type DismissibleAlertProps = {
compact?: boolean;
floating?: boolean;
resetKey?: string | number;
dismissStorageKey?: string;
};
let floatingAlertRoot: HTMLElement | null = null;
@@ -34,6 +35,29 @@ function getFloatingAlertRoot(): HTMLElement | null {
return floatingAlertRoot;
}
function resolveDismissStorageKey(dismissStorageKey: string | undefined, resetKey: string | number | undefined): string | null {
if (!dismissStorageKey) return null;
return `govoplan.alert.dismissed:${dismissStorageKey}:${resetKey ?? "default"}`;
}
function readDismissed(storageKey: string | null): boolean {
if (!storageKey || typeof window === "undefined") return false;
try {
return window.localStorage.getItem(storageKey) === "1";
} catch {
return false;
}
}
function writeDismissed(storageKey: string | null): void {
if (!storageKey || typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, "1");
} catch {
// localStorage may be unavailable in private or restricted contexts.
}
}
export default function DismissibleAlert({
tone = "info",
children,
@@ -41,16 +65,24 @@ export default function DismissibleAlert({
className = "",
compact = false,
floating = false,
resetKey
resetKey,
dismissStorageKey
}: DismissibleAlertProps) {
const [visible, setVisible] = useState(true);
const storageKey = resolveDismissStorageKey(dismissStorageKey, resetKey);
const [alertState, setAlertState] = useState(() => ({ storageKey, visible: !readDismissed(storageKey) }));
const visible = alertState.storageKey === storageKey ? alertState.visible : !readDismissed(storageKey);
useEffect(() => {
setVisible(true);
}, [resetKey, children]);
setAlertState({ storageKey, visible: !readDismissed(storageKey) });
}, [storageKey, resetKey, children]);
if (!visible) return null;
function dismiss() {
writeDismissed(storageKey);
setAlertState({ storageKey, visible: false });
}
const role = tone === "danger" || tone === "warning" ? "alert" : "status";
const alert = (
<div
@@ -60,7 +92,7 @@ export default function DismissibleAlert({
>
<div className="alert-message">{children}</div>
{dismissible && (
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={() => setVisible(false)}>
<button type="button" className="alert-dismiss" aria-label="Dismiss notice" onClick={dismiss}>
<X size={16} strokeWidth={2.4} aria-hidden="true" />
</button>
)}

View File

@@ -1,23 +1,33 @@
import { Paperclip } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
export type MessageDisplayField = {
label: string;
value?: string | null;
value?: ReactNode | null;
};
export type MessageDisplayAttachment = {
id?: string | null;
filename?: string | null;
contentType: string;
sizeBytes: number;
contentType?: string | null;
sizeBytes?: number | null;
detail?: string | null;
archiveGroup?: string | null;
archiveLabel?: string | null;
protected?: boolean | null;
protectionNote?: string | null;
};
type MessageDisplayBodyMode = "text" | "html";
type MessageDisplayPanelProps = {
title?: string | null;
fields?: MessageDisplayField[];
bodyText?: string | null;
bodyHtml?: string | null;
bodyPreview?: string | null;
preferredBodyMode?: MessageDisplayBodyMode;
deriveTextFromHtml?: boolean;
headers?: Record<string, string>;
attachments?: MessageDisplayAttachment[];
emptyText?: string;
@@ -29,57 +39,210 @@ export default function MessageDisplayPanel({
bodyText,
bodyHtml,
bodyPreview,
preferredBodyMode,
deriveTextFromHtml = true,
headers = {},
attachments = [],
emptyText = "Select an item to inspect its content."
}: MessageDisplayPanelProps) {
if (!title && fields.length === 0 && !bodyText && !bodyHtml && !bodyPreview && attachments.length === 0 && Object.keys(headers).length === 0) {
const visibleFields = fields.filter((field) => hasRenderableValue(field.value));
const headerEntries = Object.entries(headers);
const hasHtml = Boolean(bodyHtml?.trim());
const explicitTextBody = bodyText?.trim() || bodyPreview?.trim() || "";
const textBody = explicitTextBody || (deriveTextFromHtml && hasHtml ? stripHtml(bodyHtml || "") : "");
const hasText = Boolean(textBody.trim());
const defaultBodyMode = preferredBodyMode === "html" && hasHtml ? "html" : preferredBodyMode === "text" && hasText ? "text" : hasHtml ? "html" : "text";
const [bodyMode, setBodyMode] = useState<MessageDisplayBodyMode>(defaultBodyMode);
const groupedAttachments = useMemo(() => groupAttachments(attachments), [attachments]);
useEffect(() => {
setBodyMode(defaultBodyMode);
}, [defaultBodyMode, title, bodyText, bodyHtml, bodyPreview]);
if (!title && visibleFields.length === 0 && !hasText && !hasHtml && attachments.length === 0 && headerEntries.length === 0) {
return <p className="muted">{emptyText}</p>;
}
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
const activeBodyMode = bodyMode === "html" && hasHtml ? "html" : "text";
const showBodySwitch = hasHtml && hasText;
return (
<div className="message-display-panel">
<div className="message-display-header">
<h3>{title || "(no subject)"}</h3>
{fields.filter((field) => field.value).map((field) => (
<div key={field.label}>
<span>{field.label}</span>
<strong>{field.value}</strong>
</div>
))}
{visibleFields.length > 0 && (
<dl className="message-display-fields">
{visibleFields.map((field) => (
<div key={field.label}>
<dt>{field.label}</dt>
<dd>{field.value}</dd>
</div>
))}
</dl>
)}
</div>
<pre className="message-display-body">{body || "No readable body content."}</pre>
<section className="message-display-body-section" aria-label="Message body">
<div className="message-display-section-heading">
<h4>Message</h4>
{showBodySwitch && (
<div className="message-display-body-switch" role="tablist" aria-label="Message body format">
<button type="button" className={activeBodyMode === "html" ? "active" : ""} onClick={() => setBodyMode("html")}>HTML</button>
<button type="button" className={activeBodyMode === "text" ? "active" : ""} onClick={() => setBodyMode("text")}>Text</button>
</div>
)}
</div>
{activeBodyMode === "html" ? (
<iframe className="message-display-html-frame" title="Rendered HTML message body" sandbox="" srcDoc={bodyHtml || "<p>No HTML body content.</p>"} />
) : (
<pre className="message-display-body">{textBody || "No readable body content."}</pre>
)}
</section>
{attachments.length ? (
<div className="message-display-attachments">
<h4>Attachments</h4>
{attachments.map((attachment, index) => (
<div key={attachment.id || `${attachment.filename || "attachment"}-${index}`}>
<Paperclip size={14} aria-hidden="true" />
<span>{attachment.filename || "unnamed attachment"}</span>
<small>{attachment.contentType} - {formatBytes(attachment.sizeBytes)}</small>
</div>
))}
<div className="message-display-attachments-scroll">
{groupedAttachments.direct.length > 0 && (
<div className="message-display-attachment-list">
{groupedAttachments.direct.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
</div>
)}
{groupedAttachments.archives.map((archive) => (
<section className="message-display-attachment-archive" key={archive.key}>
<header>
<div>
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
</div>
{archive.protected && <small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>}
</header>
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
<div className="message-display-attachment-list">
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
</div>
</section>
))}
</div>
</div>
) : null}
<details className="message-display-headers">
<summary>Headers</summary>
<dl>
{Object.entries(headers).map(([key, value]) => (
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
))}
</dl>
</details>
{headerEntries.length > 0 && (
<details className="message-display-headers">
<summary>Headers</summary>
<dl>
{headerEntries.map(([key, value]) => (
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
))}
</dl>
</details>
)}
</div>
);
}
function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttachment; index: number }) {
const contentType = formatContentType(attachment.contentType);
const size = formatBytes(attachment.sizeBytes);
const hasMeta = Boolean(contentType || size);
return (
<div className="message-display-attachment-row">
<Paperclip size={14} aria-hidden="true" />
<span>
<strong>{attachment.filename || `Attachment ${index + 1}`}</strong>
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
{hasMeta && (
<small className="message-display-attachment-meta">
{contentType && <span title={contentType.full}>{contentType.label}</span>}
{size && <span>{size}</span>}
</small>
)}
</span>
</div>
);
}
function groupAttachments(attachments: MessageDisplayAttachment[]) {
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
const archiveMap = new Map<string, MessageDisplayAttachment[]>();
for (const attachment of attachments) {
if (!attachment.archiveGroup) continue;
const current = archiveMap.get(attachment.archiveGroup) ?? [];
current.push(attachment);
archiveMap.set(attachment.archiveGroup, current);
}
return {
direct,
archives: [...archiveMap.entries()].map(([key, items]) => ({
key,
label: items[0]?.archiveLabel || key,
protected: items.some((item) => item.protected),
protectionNote: items.find((item) => item.protectionNote)?.protectionNote ?? null,
items
}))
};
}
function attachmentKey(attachment: MessageDisplayAttachment, index: number): string {
return String(attachment.id || `${attachment.archiveGroup || "direct"}:${attachment.filename || "attachment"}:${index}`);
}
function hasRenderableValue(value: ReactNode | null | undefined): boolean {
if (value === null || value === undefined || value === false) return false;
if (typeof value === "string") return value.trim() !== "";
return true;
}
function formatProtectionNote(value: string): string {
return value
.replace(/^Password-protected ZIP(?: using)?\s*/i, "")
.replace(/^Password protected(?: using)?\s*/i, "")
.replace(/^using\s+/i, "")
.replace(/\.\s*Encryption:/i, " · Encryption:")
.trim();
}
function formatContentType(value?: string | null): { label: string; full: string } | null {
const full = value?.trim();
if (!full) return null;
const normalized = full.toLowerCase();
const known: Record<string, string> = {
"application/pdf": "PDF",
"application/zip": "ZIP archive",
"application/x-zip-compressed": "ZIP archive",
"application/octet-stream": "Binary file",
"text/plain": "Plain text",
"text/html": "HTML",
"text/csv": "CSV",
"application/json": "JSON",
"image/jpeg": "JPEG image",
"image/png": "PNG image",
"image/gif": "GIF image",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX spreadsheet",
"application/vnd.ms-excel": "Excel spreadsheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX document",
"application/msword": "Word document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX presentation",
"application/vnd.ms-powerpoint": "PowerPoint presentation"
};
if (known[normalized]) return { label: known[normalized], full };
const subtype = normalized.split("/")[1]?.split(";")[0] || normalized;
const cleaned = subtype
.replace(/^vnd\./, "")
.replace(/^x-/, "")
.replace(/openxmlformats-officedocument\./g, "")
.replace(/[.+_-]+/g, " ")
.trim();
const label = titleCase(cleaned || normalized);
return { label: label.length > 34 ? `${label.slice(0, 31)}...` : label, full };
}
function titleCase(value: string): string {
return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
}
function formatBytes(value?: number | null): string {
if (!value) return "-";
if (!value) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;

View File

@@ -4,6 +4,7 @@ export type PolicySourcePathItem = string | {
label: string;
applied_fields?: string[];
appliedFields?: string[];
policy?: Record<string, unknown> | null;
};
export type PolicySourcePathProps = {

View File

@@ -0,0 +1,180 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Button from "./Button";
import DismissibleAlert from "./DismissibleAlert";
export type UnsavedNavigationAction = () => void;
export type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
const [pendingAction, setPendingAction] = useState<UnsavedNavigationAction | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
useEffect(() => {
registrationRef.current = registration;
}, [registration]);
const hasUnsavedChanges = Boolean(registration);
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
setRegistration(next);
return () => {
setRegistration((current) => current === next ? null : current);
};
}, []);
const proceed = useCallback((action: UnsavedNavigationAction) => {
setPendingAction(null);
setSaveError("");
action();
}, []);
const requestNavigation = useCallback((action: UnsavedNavigationAction) => {
const active = registrationRef.current;
if (!active) {
action();
return;
}
setSaveError("");
setPendingAction(() => action);
}, []);
useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!registrationRef.current) return;
event.preventDefault();
event.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
function onDocumentClick(event: MouseEvent) {
if (!registrationRef.current) return;
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
const target = event.target as Element | null;
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!anchor) return;
if (anchor.target && anchor.target !== "_self") return;
if (anchor.hasAttribute("download")) return;
if (anchor.getAttribute("href")?.startsWith("#")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.href === current.href) return;
event.preventDefault();
event.stopPropagation();
requestNavigation(() => {
if (destination.origin === current.origin) {
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
} else {
window.location.assign(destination.href);
}
});
}
document.addEventListener("click", onDocumentClick, true);
return () => document.removeEventListener("click", onDocumentClick, true);
}, [navigate, requestNavigation]);
async function handleSaveAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action || !active) return;
setSaving(true);
setSaveError("");
try {
const ok = await active.onSave();
if (!ok) {
setSaveError("The changes could not be saved. Please review the page message and try again.");
return;
}
proceed(action);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function handleDiscardAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action) return;
active?.onDiscard?.();
proceed(action);
}
const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges,
registerUnsavedChanges,
requestNavigation
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel unsaved-changes-dialog">
<header className="modal-header">
<h2>{registration.title ?? "Unsaved changes"}</h2>
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>x</button>
</header>
<div className="modal-body">
<p>{registration.message ?? "This page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={() => void handleSaveAndLeave()} disabled={saving}>{saving ? "Saving..." : "Save and leave"}</Button>
</footer>
</div>
</div>
)}
</UnsavedChangesContext.Provider>
);
}
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action()
};
export function useUnsavedChanges() {
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
}
export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) {
const { registerUnsavedChanges } = useUnsavedChanges();
useEffect(() => {
return registerUnsavedChanges(registration);
}, [registerUnsavedChanges, registration]);
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useState } from "react";
import Button from "../Button";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
@@ -7,17 +7,21 @@ 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;
username?: string | null;
password?: string | null;
security?: MailServerSecurity | null;
timeout_seconds?: string | number | null;
username?: string | null;
password?: string | null;
};
export type MailServerImapSettings = MailServerSmtpSettings & {
enabled?: boolean;
sent_folder?: string | null;
};
@@ -48,20 +52,20 @@ export type MailServerSettingsPanelProps = {
imap: MailServerImapSettings;
onSmtpChange: (patch: Partial<MailServerSmtpSettings>) => void;
onImapChange: (patch: Partial<MailServerImapSettings>) => void;
onImapEnabledChange?: (enabled: boolean) => 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;
imapToggleDisabled?: boolean;
imapServerDisabled?: boolean;
imapCredentialDisabled?: boolean;
imapPasswordSaved?: boolean;
imapSavedPasswordPlaceholder?: string;
imapActionDisabled?: boolean;
smtpTitle?: ReactNode;
imapTitle?: ReactNode;
smtpTestLabel?: string;
imapTestLabel?: string;
folderLookupLabel?: string;
@@ -91,29 +95,127 @@ export type MailServerSettingsPanelProps = {
disabled?: boolean;
className?: string;
floatingResults?: boolean;
initialSection?: MailServerSettingsSection;
};
const securityOptions = ["plain", "tls", "starttls"];
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,
onImapEnabledChange,
smtpCredentials,
imapCredentials,
onSmtpCredentialsChange,
onImapCredentialsChange,
smtpDisabled = false,
smtpCredentialDisabled = smtpDisabled,
smtpPasswordSaved = false,
smtpSavedPasswordPlaceholder = "••••••••",
smtpActionDisabled = smtpDisabled,
imapToggleDisabled = false,
imapServerDisabled = false,
imapCredentialDisabled = imapServerDisabled,
imapPasswordSaved = false,
imapSavedPasswordPlaceholder = "••••••••",
imapActionDisabled = imapServerDisabled,
smtpTitle = "SMTP login",
imapTitle = "IMAP sent-folder append",
smtpTestLabel = "Test SMTP",
imapTestLabel = "Test IMAP",
folderLookupLabel = "Folders...",
@@ -130,7 +232,8 @@ export default function MailServerSettingsPanel({
append,
disabled = false,
className = "",
floatingResults = false
floatingResults = false,
initialSection = "smtp"
}: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
@@ -138,92 +241,156 @@ export default function MailServerSettingsPanel({
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-settings-grid">
<section className="mail-server-subsection">
<div className="mail-server-section-heading split">
<h3>{smtpTitle}</h3>
{mockToggle && (
<ToggleSwitch
label={mockToggle.label ?? "Mock server settings"}
checked={mockToggle.checked}
disabled={disabled || mockToggle.disabled}
onChange={mockToggle.onChange}
/>
)}
</div>
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<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={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
<FormField label="Password">
<PasswordField
value={stringValue(smtp.password)}
disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onSmtpChange({ password })}
/>
</FormField>
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></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>
<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>
<section className="mail-server-subsection">
<div className="mail-server-section-heading split">
<h3>{imapTitle}</h3>
</div>
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{onImapEnabledChange && (
<div className="mail-server-field-span mail-server-toggle-row">
<ToggleSwitch label="Enable IMAP" checked={Boolean(imap.enabled)} disabled={disabled || imapToggleDisabled} onChange={onImapEnabledChange} />
<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>
)}
<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={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
<FormField label="Password">
<PasswordField
value={stringValue(imap.password)}
disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onImapChange({ password })}
/>
</FormField>
<FormField label="Security"><SecuritySelect value={stringValue(imap.security, "tls")} disabled={imapFieldsDisabled} onChange={(security) => onImapChange({ security })} /></FormField>
<FormField label="Detected/saved sent folder"><input value={stringValue(imap.sent_folder, "auto")} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ sent_folder: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
{append && (
<>
<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 folder"><input value={append.folder} disabled={disabled || append.folderDisabled} onChange={(event) => append.onFolderChange(event.target.value)} /></FormField>
</>
)}
</div>
{(onTestImap || onLookupFolders) && (
<div className="button-row compact-actions mail-server-actions">
{onTestImap && <Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>}
{onLookupFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
)}
<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>
)}
{onLookupFolders && <p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>}
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
<MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled} onUseDetected={onUseDetectedFolder} />
</section>
</section>
)}
</div>
</div>
);
@@ -278,6 +445,21 @@ function SecuritySelect({ value, disabled, onChange }: { value: string; disabled
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);