feat(mail): define standard IMAP folder mappings
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
from govoplan_core.mail.config import ImapConfig, SmtpConfig, TransportSecurity
|
from govoplan_core.mail.config import (
|
||||||
|
ImapConfig,
|
||||||
|
ImapFolderMappings,
|
||||||
|
SmtpConfig,
|
||||||
|
TransportSecurity,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["ImapConfig", "SmtpConfig", "TransportSecurity"]
|
__all__ = ["ImapConfig", "ImapFolderMappings", "SmtpConfig", "TransportSecurity"]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
class StrictModel(BaseModel):
|
class StrictModel(BaseModel):
|
||||||
@@ -34,21 +34,61 @@ class SmtpServerConfig(StrictModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ImapFolderMappings(StrictModel):
|
||||||
|
"""Profile-level names for the standard IMAP mailbox roles."""
|
||||||
|
|
||||||
|
inbox: str | None = None
|
||||||
|
sent: str | None = None
|
||||||
|
drafts: str | None = None
|
||||||
|
trash: str | None = None
|
||||||
|
archive: str | None = None
|
||||||
|
junk: str | None = None
|
||||||
|
|
||||||
|
@field_validator("*", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_folder_name(cls, value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = str(value).strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
class ImapServerConfig(StrictModel):
|
class ImapServerConfig(StrictModel):
|
||||||
host: str | None = None
|
host: str | None = None
|
||||||
port: int | None = Field(default=None, ge=1, le=65535)
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
security: TransportSecurity = TransportSecurity.TLS
|
security: TransportSecurity = TransportSecurity.TLS
|
||||||
sent_folder: str = "auto"
|
sent_folder: str = "auto"
|
||||||
|
folder_mappings: ImapFolderMappings | None = None
|
||||||
timeout_seconds: int = Field(default=30, ge=1)
|
timeout_seconds: int = Field(default=30, ge=1)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def discard_legacy_enabled(cls, value: Any) -> Any:
|
def discard_legacy_enabled(cls, value: Any) -> Any:
|
||||||
if isinstance(value, dict) and "enabled" in value:
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
data = dict(value)
|
data = dict(value)
|
||||||
data.pop("enabled", None)
|
data.pop("enabled", None)
|
||||||
|
mappings_value = data.get("folder_mappings")
|
||||||
|
mappings = (
|
||||||
|
mappings_value.model_dump(exclude_none=True)
|
||||||
|
if isinstance(mappings_value, ImapFolderMappings)
|
||||||
|
else dict(mappings_value)
|
||||||
|
if isinstance(mappings_value, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
mapped_sent = str(mappings.get("sent") or "").strip()
|
||||||
|
legacy_sent = str(data.get("sent_folder") or "").strip()
|
||||||
|
if mapped_sent:
|
||||||
|
# The typed mapping is canonical when both new and legacy callers
|
||||||
|
# provide a Sent value. Keep the legacy field synchronized for
|
||||||
|
# existing Campaign append consumers.
|
||||||
|
data["sent_folder"] = mapped_sent
|
||||||
|
elif legacy_sent and legacy_sent != "auto":
|
||||||
|
data["sent_folder"] = legacy_sent
|
||||||
|
mappings["sent"] = legacy_sent
|
||||||
|
if mappings:
|
||||||
|
data["folder_mappings"] = mappings
|
||||||
return data
|
return data
|
||||||
return value
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def apply_default_port(self) -> "ImapServerConfig":
|
def apply_default_port(self) -> "ImapServerConfig":
|
||||||
|
|||||||
@@ -2,10 +2,39 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_core.mail.config import normalize_split_transport_credentials
|
from govoplan_core.mail.config import ImapServerConfig, normalize_split_transport_credentials
|
||||||
|
|
||||||
|
|
||||||
class MailConfigTests(unittest.TestCase):
|
class MailConfigTests(unittest.TestCase):
|
||||||
|
def test_legacy_sent_folder_populates_standard_mapping(self) -> None:
|
||||||
|
config = ImapServerConfig.model_validate(
|
||||||
|
{"host": "imap.example.test", "sent_folder": " Sent Items "}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("Sent Items", config.sent_folder)
|
||||||
|
self.assertIsNotNone(config.folder_mappings)
|
||||||
|
assert config.folder_mappings is not None
|
||||||
|
self.assertEqual("Sent Items", config.folder_mappings.sent)
|
||||||
|
|
||||||
|
def test_standard_sent_mapping_remains_legacy_append_default(self) -> None:
|
||||||
|
config = ImapServerConfig.model_validate(
|
||||||
|
{
|
||||||
|
"host": "imap.example.test",
|
||||||
|
"sent_folder": "Old Sent",
|
||||||
|
"folder_mappings": {
|
||||||
|
"inbox": " INBOX ",
|
||||||
|
"sent": "Sent Items",
|
||||||
|
"junk": " ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("Sent Items", config.sent_folder)
|
||||||
|
assert config.folder_mappings is not None
|
||||||
|
self.assertEqual("INBOX", config.folder_mappings.inbox)
|
||||||
|
self.assertEqual("Sent Items", config.folder_mappings.sent)
|
||||||
|
self.assertIsNone(config.folder_mappings.junk)
|
||||||
|
|
||||||
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
|
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
|
||||||
payload = normalize_split_transport_credentials(
|
payload = normalize_split_transport_credentials(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
MailCredentialEnvelope,
|
MailCredentialEnvelope,
|
||||||
|
MailImapFolderMappings,
|
||||||
MailImapTransportSettings,
|
MailImapTransportSettings,
|
||||||
MailProfilePatternKey,
|
MailProfilePatternKey,
|
||||||
MailProfilePolicy,
|
MailProfilePolicy,
|
||||||
@@ -14,6 +15,8 @@ import type {
|
|||||||
export type {
|
export type {
|
||||||
MailCredentialEnvelope,
|
MailCredentialEnvelope,
|
||||||
MailCredentialPolicy,
|
MailCredentialPolicy,
|
||||||
|
MailImapFolderMappingKey,
|
||||||
|
MailImapFolderMappings,
|
||||||
MailProfilePatternKey,
|
MailProfilePatternKey,
|
||||||
MailProfilePolicy,
|
MailProfilePolicy,
|
||||||
MailProfileScope,
|
MailProfileScope,
|
||||||
@@ -56,6 +59,7 @@ export type MailImapFolderListResponse = {
|
|||||||
message: string;
|
message: string;
|
||||||
folders: MailImapFolderResponse[];
|
folders: MailImapFolderResponse[];
|
||||||
detected_sent_folder?: string | null;
|
detected_sent_folder?: string | null;
|
||||||
|
detected_folder_mappings?: MailImapFolderMappings | null;
|
||||||
from_cache?: boolean;
|
from_cache?: boolean;
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
indexed_at?: string | null;
|
indexed_at?: string | null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FormGrid } from "../ContentGrid";
|
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 Button from "../Button";
|
||||||
import { CredentialFields } from "../CredentialPanel";
|
import { CredentialFields } from "../CredentialPanel";
|
||||||
import DismissibleAlert from "../DismissibleAlert";
|
import DismissibleAlert from "../DismissibleAlert";
|
||||||
@@ -24,6 +25,7 @@ export type MailServerSmtpSettings = {
|
|||||||
|
|
||||||
export type MailServerImapSettings = MailServerSmtpSettings & {
|
export type MailServerImapSettings = MailServerSmtpSettings & {
|
||||||
sent_folder?: string | null;
|
sent_folder?: string | null;
|
||||||
|
folder_mappings?: MailImapFolderMappings | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailServerConnectionTestResult = {
|
export type MailServerConnectionTestResult = {
|
||||||
@@ -44,6 +46,7 @@ export type MailServerFolderLookupResult = {
|
|||||||
security?: MailServerSecurity | null;
|
security?: MailServerSecurity | null;
|
||||||
message: string;
|
message: string;
|
||||||
detected_sent_folder?: string | null;
|
detected_sent_folder?: string | null;
|
||||||
|
detected_folder_mappings?: MailImapFolderMappings | null;
|
||||||
folders?: {name: string;flags?: string[];}[];
|
folders?: {name: string;flags?: string[];}[];
|
||||||
details?: Record<string, unknown> | null;
|
details?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
@@ -77,8 +80,10 @@ export type MailServerSettingsPanelProps = {
|
|||||||
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
||||||
onTestSmtp?: () => void;
|
onTestSmtp?: () => void;
|
||||||
onTestImap?: () => void;
|
onTestImap?: () => void;
|
||||||
|
onLookupImapFolders?: () => void;
|
||||||
smtpTestResult?: MailServerConnectionTestResult | null;
|
smtpTestResult?: MailServerConnectionTestResult | null;
|
||||||
imapTestResult?: MailServerConnectionTestResult | null;
|
imapTestResult?: MailServerConnectionTestResult | null;
|
||||||
|
imapFolderLookupResult?: MailServerFolderLookupResult | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
floatingResults?: boolean;
|
floatingResults?: boolean;
|
||||||
@@ -91,6 +96,17 @@ export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
|
|||||||
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
|
||||||
const securityOptions = mailServerSecurityOptions;
|
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 {
|
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
|
||||||
if (security === "tls") return 465;
|
if (security === "tls") return 465;
|
||||||
if (security === "plain") return 25;
|
if (security === "plain") return 25;
|
||||||
@@ -171,12 +187,15 @@ options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fal
|
|||||||
export function mailImapSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
export function mailImapSettingsPayload<TSecurity extends string = MailServerSecurityOption>(
|
||||||
settings: MailServerImapSettings,
|
settings: MailServerImapSettings,
|
||||||
options: {fallbackSecurity: TSecurity;allowedSecurity?: readonly TSecurity[];fallbackTimeoutSeconds?: number;})
|
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 {
|
return {
|
||||||
host: mailTextOrNull(settings.host),
|
host: mailTextOrNull(settings.host),
|
||||||
port: mailNumberOrNull(settings.port),
|
port: mailNumberOrNull(settings.port),
|
||||||
security: normalizeMailServerSecurity(settings.security ? String(settings.security) : null, { fallback: options.fallbackSecurity, allowedSecurity: options.allowedSecurity }),
|
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)
|
timeout_seconds: mailNumberOrDefault(settings.timeout_seconds, options.fallbackTimeoutSeconds ?? 30)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -224,8 +243,10 @@ export default function MailServerSettingsPanel({
|
|||||||
busyAction = null,
|
busyAction = null,
|
||||||
onTestSmtp,
|
onTestSmtp,
|
||||||
onTestImap,
|
onTestImap,
|
||||||
|
onLookupImapFolders,
|
||||||
smtpTestResult = null,
|
smtpTestResult = null,
|
||||||
imapTestResult = null,
|
imapTestResult = null,
|
||||||
|
imapFolderLookupResult = null,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
floatingResults = false,
|
floatingResults = false,
|
||||||
@@ -292,6 +313,29 @@ export default function MailServerSettingsPanel({
|
|||||||
onImapChange(patch);
|
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 (
|
return (
|
||||||
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
<div className={`mail-server-settings-panel ${className}`.trim()}>
|
||||||
{showSectionSwitcher &&
|
{showSectionSwitcher &&
|
||||||
@@ -355,12 +399,29 @@ export default function MailServerSettingsPanel({
|
|||||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
|
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
|
||||||
}
|
}
|
||||||
</FormGrid>
|
</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">
|
<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>
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
|
||||||
|
{imapFolderLookupResult &&
|
||||||
|
<MailServerFolderLookupResultView
|
||||||
|
result={imapFolderLookupResult}
|
||||||
|
disabled={imapFieldsDisabled}
|
||||||
|
onUseDetectedMappings={useDetectedImapFolderMappings}
|
||||||
|
floatingFailures={floatingResults} />
|
||||||
|
}
|
||||||
</section>
|
</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;}) {
|
export function MailServerActionResult({ result, floating = false }: {result: MailServerConnectionTestResult | null | undefined;floating?: boolean;}) {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
const authenticated = result.details?.authenticated;
|
const authenticated = result.details?.authenticated;
|
||||||
@@ -386,20 +490,23 @@ export function MailServerFolderLookupResultView({
|
|||||||
result,
|
result,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onUseDetected,
|
onUseDetected,
|
||||||
|
onUseDetectedMappings,
|
||||||
compact = true,
|
compact = true,
|
||||||
floatingFailures = false
|
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) return null;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
|
return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = result.folders ?? [];
|
const folders = result.folders ?? [];
|
||||||
|
const hasDetectedMappings = Object.values(result.detected_folder_mappings ?? {}).some((value) => Boolean(value));
|
||||||
return (
|
return (
|
||||||
<DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
<DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||||
<p>{result.message}</p>
|
<p>{result.message}</p>
|
||||||
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></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>}
|
{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 &&
|
{folders.length > 0 &&
|
||||||
<div className="mail-server-folder-chip-list">
|
<div className="mail-server-folder-chip-list">
|
||||||
{folders.slice(0, 12).map((folder) =>
|
{folders.slice(0, 12).map((folder) =>
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import type { PlatformTranslations } from "../types";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
"en": {
|
"en": {
|
||||||
|
"i18n:govoplan-core.standard_folder_mappings": "Standard folder mappings",
|
||||||
|
"i18n:govoplan-core.standard_folder_mappings_help": "Map each standard mailbox role to a folder exposed by this IMAP account. Leave a field empty to use automatic detection.",
|
||||||
|
"i18n:govoplan-core.inbox_folder": "Inbox folder",
|
||||||
|
"i18n:govoplan-core.sent_folder": "Sent folder",
|
||||||
|
"i18n:govoplan-core.drafts_folder": "Drafts folder",
|
||||||
|
"i18n:govoplan-core.trash_folder": "Trash folder",
|
||||||
|
"i18n:govoplan-core.archive_folder": "Archive folder",
|
||||||
|
"i18n:govoplan-core.junk_folder": "Junk folder",
|
||||||
|
"i18n:govoplan-core.auto_detect": "Auto-detect",
|
||||||
|
"i18n:govoplan-core.detect_folders": "Detect folders",
|
||||||
|
"i18n:govoplan-core.loading_folders": "Loading folders…",
|
||||||
|
"i18n:govoplan-core.use_detected_folder_mappings": "Use detected mappings",
|
||||||
"i18n:govoplan-core.generate_password.bd5bede8": "Generate password",
|
"i18n:govoplan-core.generate_password.bd5bede8": "Generate password",
|
||||||
"i18n:govoplan-core.use_password.2e1913a6": "Use password",
|
"i18n:govoplan-core.use_password.2e1913a6": "Use password",
|
||||||
"i18n:govoplan-core.length.adc95605": "Length",
|
"i18n:govoplan-core.length.adc95605": "Length",
|
||||||
@@ -677,6 +689,18 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
|
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
|
"i18n:govoplan-core.standard_folder_mappings": "Zuordnung der Standardordner",
|
||||||
|
"i18n:govoplan-core.standard_folder_mappings_help": "Ordnen Sie jede Standardfunktion einem Ordner dieses IMAP-Kontos zu. Lassen Sie ein Feld leer, um die automatische Erkennung zu verwenden.",
|
||||||
|
"i18n:govoplan-core.inbox_folder": "Posteingang",
|
||||||
|
"i18n:govoplan-core.sent_folder": "Gesendet",
|
||||||
|
"i18n:govoplan-core.drafts_folder": "Entwürfe",
|
||||||
|
"i18n:govoplan-core.trash_folder": "Papierkorb",
|
||||||
|
"i18n:govoplan-core.archive_folder": "Archiv",
|
||||||
|
"i18n:govoplan-core.junk_folder": "Spam",
|
||||||
|
"i18n:govoplan-core.auto_detect": "Automatisch erkennen",
|
||||||
|
"i18n:govoplan-core.detect_folders": "Ordner erkennen",
|
||||||
|
"i18n:govoplan-core.loading_folders": "Ordner werden geladen…",
|
||||||
|
"i18n:govoplan-core.use_detected_folder_mappings": "Erkannte Zuordnung verwenden",
|
||||||
"i18n:govoplan-core.generate_password.bd5bede8": "Passwort generieren",
|
"i18n:govoplan-core.generate_password.bd5bede8": "Passwort generieren",
|
||||||
"i18n:govoplan-core.use_password.2e1913a6": "Passwort verwenden",
|
"i18n:govoplan-core.use_password.2e1913a6": "Passwort verwenden",
|
||||||
"i18n:govoplan-core.length.adc95605": "Länge",
|
"i18n:govoplan-core.length.adc95605": "Länge",
|
||||||
|
|||||||
+1
-1
@@ -219,7 +219,7 @@ export { UnsavedChangesProvider, useGuardedNavigate, useRegisterUnsavedChanges,
|
|||||||
export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard";
|
export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard";
|
||||||
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
|
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
|
||||||
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
|
||||||
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
export { default as MailServerSettingsPanel, MailImapFolderMappingsEditor, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapFolderMappingKeys, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
|
||||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
|
||||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||||
export { default as DocumentationHelpLink, DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
export { default as DocumentationHelpLink, DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
||||||
|
|||||||
@@ -3529,6 +3529,16 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
.mail-server-folder-mappings {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: var(--border-line);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.mail-server-folder-mappings-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
.mail-server-folder-field {
|
.mail-server-folder-field {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -791,8 +791,13 @@ export type MailTransportSettings = {
|
|||||||
timeout_seconds?: number | null;
|
timeout_seconds?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MailImapFolderMappingKey = "inbox" | "sent" | "drafts" | "trash" | "archive" | "junk";
|
||||||
|
|
||||||
|
export type MailImapFolderMappings = Partial<Record<MailImapFolderMappingKey, string | null>>;
|
||||||
|
|
||||||
export type MailImapTransportSettings = MailTransportSettings & {
|
export type MailImapTransportSettings = MailTransportSettings & {
|
||||||
sent_folder?: string | null;
|
sent_folder?: string | null;
|
||||||
|
folder_mappings?: MailImapFolderMappings | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailServerProfileCredentials = {
|
export type MailServerProfileCredentials = {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import CredentialPanel from "../src/components/CredentialPanel";
|
import CredentialPanel from "../src/components/CredentialPanel";
|
||||||
import PasswordField from "../src/components/PasswordField";
|
import PasswordField from "../src/components/PasswordField";
|
||||||
import MessageDisplayPanel, { buildSafeMessageHtmlDocument } from "../src/components/MessageDisplayPanel";
|
import MessageDisplayPanel, { buildSafeMessageHtmlDocument } from "../src/components/MessageDisplayPanel";
|
||||||
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
import MailServerSettingsPanel, { MailServerFolderLookupResultView, mailImapSettingsPayload, normalizeMailImapFolderMappings, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
@@ -146,6 +146,66 @@ assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP sa
|
|||||||
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
||||||
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
||||||
|
|
||||||
|
assertDeepEqual(
|
||||||
|
normalizeMailImapFolderMappings({ inbox: " INBOX ", sent: "Sent Items", junk: " " }),
|
||||||
|
{ inbox: "INBOX", sent: "Sent Items", drafts: null, trash: null, archive: null, junk: null },
|
||||||
|
"standard IMAP mappings are normalized without inventing folder names"
|
||||||
|
);
|
||||||
|
assertDeepEqual(
|
||||||
|
mailImapSettingsPayload(
|
||||||
|
{
|
||||||
|
host: "imap.example.org",
|
||||||
|
port: 993,
|
||||||
|
security: "tls",
|
||||||
|
sent_folder: "Legacy Sent",
|
||||||
|
folder_mappings: { sent: "Mapped Sent", archive: "Archive" },
|
||||||
|
timeout_seconds: 30
|
||||||
|
},
|
||||||
|
{ fallbackSecurity: "tls" }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
host: "imap.example.org",
|
||||||
|
port: 993,
|
||||||
|
security: "tls",
|
||||||
|
sent_folder: "Mapped Sent",
|
||||||
|
folder_mappings: { inbox: null, sent: "Mapped Sent", drafts: null, trash: null, archive: "Archive", junk: null },
|
||||||
|
timeout_seconds: 30
|
||||||
|
},
|
||||||
|
"the typed Sent mapping remains synchronized with the legacy append field"
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedImapSettingsPanel = renderToStaticMarkup(
|
||||||
|
<MailServerSettingsPanel
|
||||||
|
initialSection="imap"
|
||||||
|
visibleSections={["imap"]}
|
||||||
|
mode="server"
|
||||||
|
smtp={{}}
|
||||||
|
imap={{
|
||||||
|
host: "imap.example.org",
|
||||||
|
port: 993,
|
||||||
|
security: "tls",
|
||||||
|
sent_folder: "Sent",
|
||||||
|
folder_mappings: { inbox: "INBOX", sent: "Sent" },
|
||||||
|
timeout_seconds: 30
|
||||||
|
}}
|
||||||
|
onSmtpChange={noop}
|
||||||
|
onImapChange={noop}
|
||||||
|
onLookupImapFolders={noop}
|
||||||
|
imapFolderLookupResult={{
|
||||||
|
ok: true,
|
||||||
|
protocol: "imap",
|
||||||
|
message: "Folders loaded",
|
||||||
|
detected_sent_folder: "Sent",
|
||||||
|
detected_folder_mappings: { inbox: "INBOX", sent: "Sent", drafts: "Drafts" },
|
||||||
|
folders: [{ name: "INBOX" }, { name: "Sent" }, { name: "Drafts" }]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert(mappedImapSettingsPanel.includes("i18n:govoplan-core.standard_folder_mappings"), "profile IMAP settings render the shared standard-folder editor");
|
||||||
|
assert(mappedImapSettingsPanel.includes("i18n:govoplan-core.detect_folders"), "profile IMAP settings expose folder discovery");
|
||||||
|
assert(mappedImapSettingsPanel.includes("i18n:govoplan-core.use_detected_folder_mappings"), "detected standard mappings can be applied together");
|
||||||
|
assert(mappedImapSettingsPanel.includes('<option value="Drafts"></option>'), "discovered folder names populate mapping suggestions");
|
||||||
|
|
||||||
const folderLookupError = renderToStaticMarkup(
|
const folderLookupError = renderToStaticMarkup(
|
||||||
<MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} />
|
<MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} />
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user