campaign sending prototype
This commit is contained in:
147
webui/src/components/ExplorerTree.tsx
Normal file
147
webui/src/components/ExplorerTree.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Folder, FolderOpen } from "lucide-react";
|
||||
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
|
||||
export type ExplorerTreeNodeContext = {
|
||||
depth: number;
|
||||
active: boolean;
|
||||
expanded: boolean;
|
||||
hasChildren: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type ExplorerTreeProps<T> = {
|
||||
nodes: T[];
|
||||
getNodeId: (node: T) => string;
|
||||
getNodeLabel: (node: T) => string;
|
||||
getNodeChildren: (node: T) => T[];
|
||||
activeId?: string;
|
||||
expandedIds: Set<string>;
|
||||
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
|
||||
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||
getNodeDraggable?: (node: T, context: ExplorerTreeNodeContext) => boolean;
|
||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onDragStart?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onDragEnd?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onDragOver?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onDragLeave?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
export default function ExplorerTree<T>({
|
||||
nodes,
|
||||
getNodeId,
|
||||
getNodeLabel,
|
||||
getNodeChildren,
|
||||
activeId = "",
|
||||
expandedIds,
|
||||
onOpen,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
depth = 1,
|
||||
className = "",
|
||||
renderToggleIcon,
|
||||
renderNodeContent,
|
||||
getNodeWrapClassName,
|
||||
getNodeWrapStyle,
|
||||
getNodeButtonClassName,
|
||||
getNodeDraggable,
|
||||
onContextMenu,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop
|
||||
}: ExplorerTreeProps<T>) {
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={["explorer-tree-children", "file-tree-children", className].filter(Boolean).join(" ")}>
|
||||
{nodes.map((node) => {
|
||||
const nodeId = getNodeId(node);
|
||||
const children = getNodeChildren(node);
|
||||
const hasChildren = children.length > 0;
|
||||
const expanded = expandedIds.has(nodeId);
|
||||
const active = activeId === nodeId;
|
||||
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
|
||||
const draggable = getNodeDraggable?.(node, context) ?? false;
|
||||
|
||||
return (
|
||||
<div key={nodeId}>
|
||||
<div
|
||||
className={[
|
||||
"explorer-tree-node-wrap",
|
||||
"file-tree-node-wrap",
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""
|
||||
].filter(Boolean).join(" ")}
|
||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
draggable={draggable && !disabled}
|
||||
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
||||
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
|
||||
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
||||
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
||||
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="explorer-tree-toggle file-tree-toggle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(node, context);
|
||||
}}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${getNodeLabel(node)}`}
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
>
|
||||
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={["explorer-tree-node", "file-tree-node", getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||
onClick={() => onOpen(node, context)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<ExplorerTree
|
||||
nodes={children}
|
||||
getNodeId={getNodeId}
|
||||
getNodeLabel={getNodeLabel}
|
||||
getNodeChildren={getNodeChildren}
|
||||
activeId={activeId}
|
||||
expandedIds={expandedIds}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
renderToggleIcon={renderToggleIcon}
|
||||
renderNodeContent={renderNodeContent}
|
||||
getNodeWrapClassName={getNodeWrapClassName}
|
||||
getNodeWrapStyle={getNodeWrapStyle}
|
||||
getNodeButtonClassName={getNodeButtonClassName}
|
||||
getNodeDraggable={getNodeDraggable}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
webui/src/components/MessageDisplayPanel.tsx
Normal file
90
webui/src/components/MessageDisplayPanel.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
export type MessageDisplayField = {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
export type MessageDisplayAttachment = {
|
||||
id?: string | null;
|
||||
filename?: string | null;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
type MessageDisplayPanelProps = {
|
||||
title?: string | null;
|
||||
fields?: MessageDisplayField[];
|
||||
bodyText?: string | null;
|
||||
bodyHtml?: string | null;
|
||||
bodyPreview?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: MessageDisplayAttachment[];
|
||||
emptyText?: string;
|
||||
};
|
||||
|
||||
export default function MessageDisplayPanel({
|
||||
title,
|
||||
fields = [],
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
bodyPreview,
|
||||
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) {
|
||||
return <p className="muted">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
|
||||
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<pre className="message-display-body">{body || "No readable body content."}</pre>
|
||||
|
||||
{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>
|
||||
) : 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
function stripHtml(value: string): string {
|
||||
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
257
webui/src/components/mail/MailServerSettingsPanel.tsx
Normal file
257
webui/src/components/mail/MailServerSettingsPanel.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "../Button";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
import FormField from "../FormField";
|
||||
import ToggleSwitch from "../ToggleSwitch";
|
||||
|
||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||
|
||||
export type MailServerSmtpSettings = {
|
||||
host?: string | null;
|
||||
port?: string | number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailServerSecurity | null;
|
||||
timeout_seconds?: string | number | null;
|
||||
};
|
||||
|
||||
export type MailServerImapSettings = MailServerSmtpSettings & {
|
||||
enabled?: boolean;
|
||||
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;
|
||||
onImapEnabledChange?: (enabled: boolean) => void;
|
||||
smtpDisabled?: boolean;
|
||||
smtpCredentialDisabled?: boolean;
|
||||
smtpActionDisabled?: boolean;
|
||||
imapToggleDisabled?: boolean;
|
||||
imapServerDisabled?: boolean;
|
||||
imapCredentialDisabled?: boolean;
|
||||
imapActionDisabled?: boolean;
|
||||
smtpTitle?: ReactNode;
|
||||
imapTitle?: ReactNode;
|
||||
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;
|
||||
};
|
||||
|
||||
const securityOptions = ["plain", "tls", "starttls"];
|
||||
|
||||
export default function MailServerSettingsPanel({
|
||||
smtp,
|
||||
imap,
|
||||
onSmtpChange,
|
||||
onImapChange,
|
||||
onImapEnabledChange,
|
||||
smtpDisabled = false,
|
||||
smtpCredentialDisabled = smtpDisabled,
|
||||
smtpActionDisabled = smtpDisabled,
|
||||
imapToggleDisabled = false,
|
||||
imapServerDisabled = false,
|
||||
imapCredentialDisabled = imapServerDisabled,
|
||||
imapActionDisabled = imapServerDisabled,
|
||||
smtpTitle = "SMTP login",
|
||||
imapTitle = "IMAP sent-folder append",
|
||||
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
|
||||
}: 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;
|
||||
|
||||
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"><input type="password" value={stringValue(smtp.password)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ password: event.target.value })} /></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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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"><input type="password" value={stringValue(imap.password)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ password: event.target.value })} /></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 && (
|
||||
<>
|
||||
<div className="mail-server-field-span mail-server-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>}
|
||||
</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>
|
||||
</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 stringValue(value: string | number | null | undefined, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
Reference in New Issue
Block a user