feat(mail): define standard IMAP folder mappings
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { FormGrid } from "../ContentGrid";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import type { MailImapFolderMappingKey, MailImapFolderMappings } from "../../types";
|
||||
import Button from "../Button";
|
||||
import { CredentialFields } from "../CredentialPanel";
|
||||
import DismissibleAlert from "../DismissibleAlert";
|
||||
@@ -24,6 +25,7 @@ export type MailServerSmtpSettings = {
|
||||
|
||||
export type MailServerImapSettings = MailServerSmtpSettings & {
|
||||
sent_folder?: string | null;
|
||||
folder_mappings?: MailImapFolderMappings | null;
|
||||
};
|
||||
|
||||
export type MailServerConnectionTestResult = {
|
||||
@@ -44,6 +46,7 @@ export type MailServerFolderLookupResult = {
|
||||
security?: MailServerSecurity | null;
|
||||
message: string;
|
||||
detected_sent_folder?: string | null;
|
||||
detected_folder_mappings?: MailImapFolderMappings | null;
|
||||
folders?: {name: string;flags?: string[];}[];
|
||||
details?: Record<string, unknown> | null;
|
||||
};
|
||||
@@ -77,8 +80,10 @@ export type MailServerSettingsPanelProps = {
|
||||
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
||||
onTestSmtp?: () => void;
|
||||
onTestImap?: () => void;
|
||||
onLookupImapFolders?: () => void;
|
||||
smtpTestResult?: MailServerConnectionTestResult | null;
|
||||
imapTestResult?: MailServerConnectionTestResult | null;
|
||||
imapFolderLookupResult?: MailServerFolderLookupResult | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
floatingResults?: boolean;
|
||||
@@ -91,6 +96,17 @@ export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
||||
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
||||
const securityOptions = mailServerSecurityOptions;
|
||||
|
||||
export const mailImapFolderMappingKeys = ["inbox", "sent", "drafts", "trash", "archive", "junk"] as const satisfies readonly MailImapFolderMappingKey[];
|
||||
|
||||
const mailImapFolderMappingLabels: Record<MailImapFolderMappingKey, string> = {
|
||||
inbox: "i18n:govoplan-core.inbox_folder",
|
||||
sent: "i18n:govoplan-core.sent_folder",
|
||||
drafts: "i18n:govoplan-core.drafts_folder",
|
||||
trash: "i18n:govoplan-core.trash_folder",
|
||||
archive: "i18n:govoplan-core.archive_folder",
|
||||
junk: "i18n:govoplan-core.junk_folder"
|
||||
};
|
||||
|
||||
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
||||
if (security === "tls") return 465;
|
||||
if (security === "plain") return 25;
|
||||
@@ -171,12 +187,15 @@ options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fal
|
||||
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;} {
|
||||
: {host: string | null;port: number | null;security: TSecurity;sent_folder: string;folder_mappings?: MailImapFolderMappings;timeout_seconds: number;} {
|
||||
const folderMappings = normalizeMailImapFolderMappings(settings.folder_mappings);
|
||||
const sentFolder = folderMappings.sent || mailTextOrNull(settings.sent_folder) || "auto";
|
||||
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",
|
||||
sent_folder: sentFolder,
|
||||
...(settings.folder_mappings !== undefined ? { folder_mappings: folderMappings } : {}),
|
||||
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
|
||||
};
|
||||
}
|
||||
@@ -224,8 +243,10 @@ export default function MailServerSettingsPanel({
|
||||
busyAction = null,
|
||||
onTestSmtp,
|
||||
onTestImap,
|
||||
onLookupImapFolders,
|
||||
smtpTestResult = null,
|
||||
imapTestResult = null,
|
||||
imapFolderLookupResult = null,
|
||||
disabled = false,
|
||||
className = "",
|
||||
floatingResults = false,
|
||||
@@ -292,6 +313,29 @@ export default function MailServerSettingsPanel({
|
||||
onImapChange(patch);
|
||||
}
|
||||
|
||||
function patchImapFolderMapping(key: MailImapFolderMappingKey, value: string) {
|
||||
const mappings = {
|
||||
...normalizeMailImapFolderMappings(imap.folder_mappings),
|
||||
[key]: mailTextOrNull(value)
|
||||
};
|
||||
onImapChange({
|
||||
folder_mappings: mappings,
|
||||
...(key === "sent" ? { sent_folder: mappings.sent || "auto" } : {})
|
||||
});
|
||||
}
|
||||
|
||||
function useDetectedImapFolderMappings() {
|
||||
const detected = normalizeMailImapFolderMappings(imapFolderLookupResult?.detected_folder_mappings);
|
||||
const detectedValues = Object.fromEntries(
|
||||
Object.entries(detected).filter(([, value]) => Boolean(value))
|
||||
) as MailImapFolderMappings;
|
||||
const mappings = { ...normalizeMailImapFolderMappings(imap.folder_mappings), ...detectedValues };
|
||||
onImapChange({
|
||||
folder_mappings: mappings,
|
||||
sent_folder: mappings.sent || imapFolderLookupResult?.detected_sent_folder || "auto"
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||
{showSectionSwitcher &&
|
||||
@@ -355,12 +399,29 @@ export default function MailServerSettingsPanel({
|
||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
|
||||
}
|
||||
</FormGrid>
|
||||
{onTestImap &&
|
||||
{showServerFields && imap.folder_mappings !== undefined &&
|
||||
<MailImapFolderMappingsEditor
|
||||
value={imap.folder_mappings}
|
||||
folders={imapFolderLookupResult?.folders}
|
||||
disabled={imapFieldsDisabled}
|
||||
onChange={patchImapFolderMapping} />
|
||||
}
|
||||
{(onTestImap || onLookupImapFolders) &&
|
||||
<div className="button-row compact-actions mail-server-actions">
|
||||
{onLookupImapFolders && <Button type="button" onClick={onLookupImapFolders} disabled={imapActionsDisabled || busyAction === "folders"} disabledReason={busyAction === "folders" ? "IMAP folder discovery is already running." : imapActionsDisabled ? imapActionDisabledReason : undefined}>{busyAction === "folders" ? "i18n:govoplan-core.loading_folders" : "i18n:govoplan-core.detect_folders"}</Button>}
|
||||
{onTestImap &&
|
||||
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"} disabledReason={busyAction === "imap" ? "IMAP connection testing is already running." : imapActionsDisabled ? imapActionDisabledReason : undefined}>{busyAction === "imap" ? "i18n:govoplan-core.testing.15ccc832" : imapTestLabel}</Button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||
{imapFolderLookupResult &&
|
||||
<MailServerFolderLookupResultView
|
||||
result={imapFolderLookupResult}
|
||||
disabled={imapFieldsDisabled}
|
||||
onUseDetectedMappings={useDetectedImapFolderMappings}
|
||||
floatingFailures={floatingResults} />
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -369,6 +430,49 @@ export default function MailServerSettingsPanel({
|
||||
|
||||
}
|
||||
|
||||
export function normalizeMailImapFolderMappings(value: MailImapFolderMappings | null | undefined): MailImapFolderMappings {
|
||||
return Object.fromEntries(
|
||||
mailImapFolderMappingKeys.map((key) => [key, mailTextOrNull(value?.[key])])
|
||||
) as MailImapFolderMappings;
|
||||
}
|
||||
|
||||
export function MailImapFolderMappingsEditor({
|
||||
value,
|
||||
folders = [],
|
||||
disabled = false,
|
||||
onChange
|
||||
}: {
|
||||
value: MailImapFolderMappings | null | undefined;
|
||||
folders?: {name: string;flags?: string[];}[];
|
||||
disabled?: boolean;
|
||||
onChange: (key: MailImapFolderMappingKey, value: string) => void;
|
||||
}) {
|
||||
const listId = useId();
|
||||
const mappings = normalizeMailImapFolderMappings(value);
|
||||
const options = [...new Set(folders.map((folder) => folder.name).filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
||||
return (
|
||||
<section className="mail-server-folder-mappings" aria-label="i18n:govoplan-core.standard_folder_mappings">
|
||||
<div className="mail-server-folder-mappings-heading">
|
||||
<strong>i18n:govoplan-core.standard_folder_mappings</strong>
|
||||
<span className="muted small-note">i18n:govoplan-core.standard_folder_mappings_help</span>
|
||||
</div>
|
||||
<FormGrid columns={2} collapseAt="wide" className="mail-server-form-grid">
|
||||
{mailImapFolderMappingKeys.map((key) =>
|
||||
<FormField key={key} label={mailImapFolderMappingLabels[key]}>
|
||||
<input
|
||||
list={listId}
|
||||
value={mappings[key] || ""}
|
||||
disabled={disabled}
|
||||
placeholder="i18n:govoplan-core.auto_detect"
|
||||
onChange={(event) => onChange(key, event.target.value)} />
|
||||
</FormField>
|
||||
)}
|
||||
</FormGrid>
|
||||
<datalist id={listId}>{options.map((name) => <option key={name} value={name} />)}</datalist>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailServerActionResult({ result, floating = false }: {result: MailServerConnectionTestResult | null | undefined;floating?: boolean;}) {
|
||||
if (!result) return null;
|
||||
const authenticated = result.details?.authenticated;
|
||||
@@ -386,20 +490,23 @@ export function MailServerFolderLookupResultView({
|
||||
result,
|
||||
disabled = false,
|
||||
onUseDetected,
|
||||
onUseDetectedMappings,
|
||||
compact = true,
|
||||
floatingFailures = false
|
||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
|
||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;onUseDetectedMappings?: () => 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 ?? [];
|
||||
const hasDetectedMappings = Object.values(result.detected_folder_mappings ?? {}).some((value) => Boolean(value));
|
||||
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>}
|
||||
{hasDetectedMappings && onUseDetectedMappings && <Button type="button" onClick={onUseDetectedMappings} disabled={disabled}>i18n:govoplan-core.use_detected_folder_mappings</Button>}
|
||||
{folders.length > 0 &&
|
||||
<div className="mail-server-folder-chip-list">
|
||||
{folders.slice(0, 12).map((folder) =>
|
||||
|
||||
Reference in New Issue
Block a user