diff --git a/src/govoplan_core/mail/__init__.py b/src/govoplan_core/mail/__init__.py index 2124ddb..9bd442c 100644 --- a/src/govoplan_core/mail/__init__.py +++ b/src/govoplan_core/mail/__init__.py @@ -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"] diff --git a/src/govoplan_core/mail/config.py b/src/govoplan_core/mail/config.py index 12fb67e..f80cce9 100644 --- a/src/govoplan_core/mail/config.py +++ b/src/govoplan_core/mail/config.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum 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): @@ -34,21 +34,61 @@ class SmtpServerConfig(StrictModel): 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): host: str | None = None port: int | None = Field(default=None, ge=1, le=65535) security: TransportSecurity = TransportSecurity.TLS sent_folder: str = "auto" + folder_mappings: ImapFolderMappings | None = None timeout_seconds: int = Field(default=30, ge=1) @model_validator(mode="before") @classmethod def discard_legacy_enabled(cls, value: Any) -> Any: - if isinstance(value, dict) and "enabled" in value: - data = dict(value) - data.pop("enabled", None) - return data - return value + if not isinstance(value, dict): + return value + data = dict(value) + 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 @model_validator(mode="after") def apply_default_port(self) -> "ImapServerConfig": diff --git a/tests/test_mail_config.py b/tests/test_mail_config.py index 804682b..306eb9b 100644 --- a/tests/test_mail_config.py +++ b/tests/test_mail_config.py @@ -2,10 +2,39 @@ from __future__ import annotations 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): + 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: payload = normalize_split_transport_credentials( { diff --git a/webui/src/api/mailContracts.ts b/webui/src/api/mailContracts.ts index 6ba53a7..5b05999 100644 --- a/webui/src/api/mailContracts.ts +++ b/webui/src/api/mailContracts.ts @@ -1,5 +1,6 @@ import type { MailCredentialEnvelope, + MailImapFolderMappings, MailImapTransportSettings, MailProfilePatternKey, MailProfilePolicy, @@ -14,6 +15,8 @@ import type { export type { MailCredentialEnvelope, MailCredentialPolicy, + MailImapFolderMappingKey, + MailImapFolderMappings, MailProfilePatternKey, MailProfilePolicy, MailProfileScope, @@ -56,6 +59,7 @@ export type MailImapFolderListResponse = { message: string; folders: MailImapFolderResponse[]; detected_sent_folder?: string | null; + detected_folder_mappings?: MailImapFolderMappings | null; from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null; diff --git a/webui/src/components/mail/MailServerSettingsPanel.tsx b/webui/src/components/mail/MailServerSettingsPanel.tsx index 0b0d544..2f8e2b5 100644 --- a/webui/src/components/mail/MailServerSettingsPanel.tsx +++ b/webui/src/components/mail/MailServerSettingsPanel.tsx @@ -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 | 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 = { + 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( 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 (
{showSectionSwitcher && @@ -355,12 +399,29 @@ export default function MailServerSettingsPanel({ savedPasswordPlaceholder={imapSavedPasswordPlaceholder} /> } - {onTestImap && + {showServerFields && imap.folder_mappings !== undefined && + + } + {(onTestImap || onLookupImapFolders) &&
+ {onLookupImapFolders && } + {onTestImap && + }
} + {imapFolderLookupResult && + + } } @@ -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 ( +
+
+ i18n:govoplan-core.standard_folder_mappings + i18n:govoplan-core.standard_folder_mappings_help +
+ + {mailImapFolderMappingKeys.map((key) => + + onChange(key, event.target.value)} /> + + )} + + {options.map((name) => +
+ ); +} + 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 {result.message}; } const folders = result.folders ?? []; + const hasDetectedMappings = Object.values(result.detected_folder_mappings ?? {}).some((value) => Boolean(value)); return (

{result.message}

i18n:govoplan-core.detected_sent_folder.cbf8ec8d {result.detected_sent_folder || "-"}

{result.detected_sent_folder && onUseDetected && } + {hasDetectedMappings && onUseDetectedMappings && } {folders.length > 0 &&
{folders.slice(0, 12).map((folder) => diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 6b199a5..60e7514 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -2,6 +2,18 @@ import type { PlatformTranslations } from "../types"; export const generatedTranslations: PlatformTranslations = { "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.use_password.2e1913a6": "Use password", "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." }, "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.use_password.2e1913a6": "Passwort verwenden", "i18n:govoplan-core.length.adc95605": "Länge", diff --git a/webui/src/index.ts b/webui/src/index.ts index 0011cb9..49ab93b 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -219,7 +219,7 @@ export { UnsavedChangesProvider, useGuardedNavigate, useRegisterUnsavedChanges, export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard"; export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard"; 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 { default as FieldLabel } from "./components/help/FieldLabel"; export { default as DocumentationHelpLink, DocumentationHelpProvider } from "./components/help/DocumentationHelpLink"; diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index f301aeb..44fa25c 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -3529,6 +3529,16 @@ justify-content: flex-end; 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 { align-items: stretch; } diff --git a/webui/src/types.ts b/webui/src/types.ts index 7aa4880..d3c3f1e 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -791,8 +791,13 @@ export type MailTransportSettings = { timeout_seconds?: number | null; }; +export type MailImapFolderMappingKey = "inbox" | "sent" | "drafts" | "trash" | "archive" | "junk"; + +export type MailImapFolderMappings = Partial>; + export type MailImapTransportSettings = MailTransportSettings & { sent_folder?: string | null; + folder_mappings?: MailImapFolderMappings | null; }; export type MailServerProfileCredentials = { diff --git a/webui/tests/mail-components.test.tsx b/webui/tests/mail-components.test.tsx index d76a24c..702a9c8 100644 --- a/webui/tests/mail-components.test.tsx +++ b/webui/tests/mail-components.test.tsx @@ -16,7 +16,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import CredentialPanel from "../src/components/CredentialPanel"; import PasswordField from "../src/components/PasswordField"; 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 { 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("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( + +); +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(''), "discovered folder names populate mapping suggestions"); + const folderLookupError = renderToStaticMarkup( );