diff --git a/docs/MAIL_HANDBOOK.md b/docs/MAIL_HANDBOOK.md index 3e662df..f6b3953 100644 --- a/docs/MAIL_HANDBOOK.md +++ b/docs/MAIL_HANDBOOK.md @@ -93,6 +93,13 @@ configuration. It has a stable id, lifecycle state, scope, owner context, and non-secret connection metadata. Passwords are write-only encrypted values and are never returned through list/read/capability responses. +An IMAP server may map the standard Inbox, Sent, Drafts, Trash, Archive, and +Junk roles to exact provider folder names. These mappings belong to the reusable +profile/server. Empty roles retain automatic behavior. The historical +`imap.sent_folder` value is read as the Sent mapping and remains synchronized +for compatibility; a Campaign-specific Sent override still wins for that +Campaign. + Profiles may be scoped to system, tenant, user, group, or campaign context. Scope controls where a profile can be discovered; effective policy can narrow that further. A visible profile is not automatically authorized for every @@ -227,8 +234,10 @@ account's user scope. Grant `mail:profile:write_own` for self-service; `mail:profile:write` remains broad profile administration authority. 1. Choose the narrowest suitable scope and a stable, descriptive name/slug. -2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder - behavior, and timeouts. Sender/envelope/recipient constraints belong to +2. Configure SMTP, optional IMAP, TLS mode, account identity, standard folder + mappings, and timeouts. Folder discovery proposes provider-visible Inbox, + Sent, Drafts, Trash, Archive, and Junk names without mutating the mailbox. + Sender/envelope/recipient constraints belong to effective Mail policy; Campaign rate limits remain delivery configuration. 3. Enter credentials only in the dedicated credential fields. Returned profile data indicates whether credentials are configured without returning them. diff --git a/src/govoplan_mail/backend/config.py b/src/govoplan_mail/backend/config.py index 33e9525..d078025 100644 --- a/src/govoplan_mail/backend/config.py +++ b/src/govoplan_mail/backend/config.py @@ -2,6 +2,7 @@ from __future__ import annotations from govoplan_core.mail.config import ( ImapConfig, + ImapFolderMappings, ImapServerConfig, SmtpConfig, SmtpServerConfig, @@ -13,6 +14,7 @@ from govoplan_core.mail.config import ( __all__ = [ "ImapConfig", + "ImapFolderMappings", "ImapServerConfig", "SmtpConfig", "SmtpServerConfig", diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index 92bfbe2..17f6f43 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -126,7 +126,7 @@ def slugify_profile_name(value: str) -> str: def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]: - payload = config.model_dump(mode="json") + payload = config.model_dump(mode="json", exclude_none=True) username_was_supplied = "username" in config.model_fields_set password_was_supplied = "password" in config.model_fields_set username = payload.pop("username", None) diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index cf10b83..d8532c7 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -629,6 +629,44 @@ manifest = ModuleManifest( ], }, ), + DocumentationTopic( + id="mail.profile-standard-folder-mappings", + title="Map standard folders for an IMAP profile", + summary="Store Inbox, Sent, Drafts, Trash, Archive, and Junk mappings on the reusable Mail profile and populate them from bounded IMAP discovery.", + body=( + "Folder names are profile/server metadata, not Campaign settings. An authorized profile administrator may enter names directly or use folder discovery; empty mappings retain automatic behavior. Existing imap.sent_folder values are presented and persisted as the Sent mapping, while the compatibility field remains synchronized for consumers that still read it. A Campaign-specific Sent-folder override remains authoritative for that Campaign and is not rewritten by profile discovery. Folder discovery lists provider-visible names without creating, renaming, moving, or deleting remote folders." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("mail_admin", "campaign_admin", "mail_user"), + order=40, + conditions=( + DocumentationCondition( + required_modules=("mail",), + required_scopes=("mail:profile:read",), + any_scopes=("mail:profile:write", "mail:profile:write_own", "mail:profile:test"), + ), + ), + links=( + DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"), + DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"), + ), + related_modules=("campaigns",), + metadata={ + "kind": "workflow", + "route": "/settings?section=mail-profiles", + "screen": "Mail profiles", + "section": "IMAP standard folder mappings", + "help_contexts": ["mail.profiles", "mail.admin.profiles"], + "steps": [ + "Open an editable IMAP server in a reusable Mail profile.", + "Choose Detect folders to load the provider-visible folder names, or enter exact names manually.", + "Apply the detected mappings, review every role, and leave uncertain roles empty for automatic behavior.", + "Save the profile and reload it to verify the mappings were retained.", + ], + "verification": "Confirm legacy Sent-only profiles show the same effective Sent mapping, and confirm a Campaign-local Sent override remains unchanged.", + }, + ), DocumentationTopic( id="mail.bounce-processing", title="Delivery-status and bounce processing", diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index b1ee808..2150395 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -1179,6 +1179,7 @@ def _mailbox_folder_response( message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder, + detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {}, from_cache=from_cache, refreshing=refreshing, indexed_at=indexed_at, @@ -2276,7 +2277,7 @@ def update_profile( session, profile=profile, protocol="smtp", - config=smtp_config.model_dump(mode="json"), + config=smtp_config.model_dump(mode="json", exclude_none=True), user_id=principal.user.id, ) imap_server = None @@ -2286,7 +2287,7 @@ def update_profile( profile=profile, protocol="imap", config=( - imap_config.model_dump(mode="json") + imap_config.model_dump(mode="json", exclude_none=True) if imap_config is not None and not payload.clear_imap else None ), @@ -3063,6 +3064,7 @@ def list_imap_folder_settings( message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder, + detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {}, ) except Exception as exc: return MailImapFolderListResponse( diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index 74c7edf..8aeed4e 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -370,6 +370,7 @@ class MailImapFolderListResponse(BaseModel): message: str folders: list[MailImapFolderResponse] = Field(default_factory=list) detected_sent_folder: str | None = None + detected_folder_mappings: dict[str, str] = Field(default_factory=dict) from_cache: bool = False refreshing: bool = False indexed_at: datetime | None = None diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py index 1dc2a95..a83feb8 100644 --- a/src/govoplan_mail/backend/sending/imap.py +++ b/src/govoplan_mail/backend/sending/imap.py @@ -103,6 +103,7 @@ class ImapFolderListResult: security: str folders: list[ImapMailboxInfo] detected_sent_folder: str | None = None + detected_folder_mappings: dict[str, str] | None = None @dataclass(frozen=True, slots=True) @@ -317,25 +318,47 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str return None -def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None: - for name, flags in parsed: - if "\\sent" in flags or "\\sentmail" in flags: - return name +_STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = { + "inbox": ("\\inbox",), + "sent": ("\\sent", "\\sentmail"), + "drafts": ("\\drafts",), + "trash": ("\\trash",), + "archive": ("\\archive", "\\all"), + "junk": ("\\junk", "\\spam"), +} - common_names = [ - "Sent", - "Sent Items", - "Sent Messages", - "Gesendet", - "Gesendete Elemente", - "INBOX.Sent", - "INBOX/Sent", - ] - names = {name.lower(): name for name, _ in parsed} - for candidate in common_names: - if candidate.lower() in names: - return names[candidate.lower()] - return None +_STANDARD_FOLDER_NAMES: dict[str, tuple[str, ...]] = { + "inbox": ("INBOX", "Posteingang"), + "sent": ("Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent"), + "drafts": ("Drafts", "Entwürfe", "Entwuerfe"), + "trash": ("Trash", "Deleted Items", "Gelöscht", "Geloescht", "Papierkorb"), + "archive": ("Archive", "Archives", "Archiv"), + "junk": ("Junk", "Spam", "Junk Email", "Unerwünscht", "Unerwuenscht"), +} + + +def _detect_standard_folder_mappings(parsed: list[tuple[str, set[str]]]) -> dict[str, str]: + detected: dict[str, str] = {} + for role, accepted_flags in _STANDARD_FOLDER_FLAGS.items(): + for name, flags in parsed: + if any(flag in flags for flag in accepted_flags): + detected[role] = name + break + + names = {name.casefold(): name for name, _ in parsed} + for role, candidates in _STANDARD_FOLDER_NAMES.items(): + if role in detected: + continue + for candidate in candidates: + match = names.get(candidate.casefold()) + if match: + detected[role] = match + break + return detected + + +def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None: + return _detect_standard_folder_mappings(parsed).get("sent") def discover_sent_folder(client: imaplib.IMAP4) -> str | None: @@ -402,12 +425,18 @@ def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: name = str(item["name"]) count = sum(1 for record in records if _mock_folder_matches(record, name)) folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None)) + parsed = [ + (str(item["name"]), {str(flag).lower() for flag in item.get("flags") or []}) + for item in MOCK_IMAP_FOLDERS + ] + detected = _detect_standard_folder_mappings(parsed) return ImapFolderListResult( host=host, port=port, security=imap_config.security.value, folders=folders, - detected_sent_folder="Sent", + detected_sent_folder=detected.get("sent"), + detected_folder_mappings=detected, ) @@ -438,12 +467,14 @@ def _list_imap_folders_on_client( ) folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count)) + detected = _detect_standard_folder_mappings(parsed) return ImapFolderListResult( host=host, port=port, security=security, folders=folders, - detected_sent_folder=_detect_sent_folder(parsed), + detected_sent_folder=detected.get("sent"), + detected_folder_mappings=detected, ) diff --git a/src/govoplan_mail/backend/server_hierarchy.py b/src/govoplan_mail/backend/server_hierarchy.py index 93f49ae..ba88b4b 100644 --- a/src/govoplan_mail/backend/server_hierarchy.py +++ b/src/govoplan_mail/backend/server_hierarchy.py @@ -358,7 +358,7 @@ def initialize_profile_hierarchy( for protocol, value in (("smtp", smtp), ("imap", imap)): if value is None or protocol in existing: continue - raw = value.model_dump(mode="json") if hasattr(value, "model_dump") else dict(value) + raw = value.model_dump(mode="json", exclude_none=True) if hasattr(value, "model_dump") else dict(value) credentials = { "username": raw.pop("username", None), "password": raw.pop("password", None), diff --git a/tests/test_imap_parser.py b/tests/test_imap_parser.py index 8b8ef48..c43d990 100644 --- a/tests/test_imap_parser.py +++ b/tests/test_imap_parser.py @@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig from govoplan_mail.backend.sending.imap import ( ImapAppendError, ImapConfigurationError, + _detect_standard_folder_mappings, _detect_sent_folder, _extract_mailbox_name, _fetch_message_by_uid, @@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import ( _select_readonly, _sequence_set, append_message_to_sent, + list_imap_folders, list_imap_messages, list_imap_uids_since, ) @@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase): "Gesendet", ) + def test_detects_all_standard_folder_roles_by_flag_then_name(self): + self.assertEqual( + { + "inbox": "INBOX", + "sent": "Gesendete Elemente", + "drafts": "Entwürfe", + "trash": "Deleted", + "archive": "All Mail", + "junk": "Spam", + }, + _detect_standard_folder_mappings( + [ + ("INBOX", set()), + ("Gesendete Elemente", set()), + ("Entwürfe", set()), + ("Deleted", {"\\trash"}), + ("All Mail", {"\\all"}), + ("Spam", {"\\junk"}), + ] + ), + ) + + def test_mock_folder_listing_exposes_detected_standard_mappings(self): + result = list_imap_folders( + imap_config=ImapConfig(host="mock.imap.local"), include_status=False + ) + + self.assertEqual( + { + "inbox": "INBOX", + "sent": "Sent", + "drafts": "Drafts", + "trash": "Trash", + "archive": "Archive", + }, + result.detected_folder_mappings, + ) + self.assertEqual("Sent", result.detected_sent_folder) + class ImapMessagePaginationTests(unittest.TestCase): def test_mock_message_cursor_preserves_order_and_resets_when_stale(self): diff --git a/tests/test_mail_profile_helpers.py b/tests/test_mail_profile_helpers.py index 6213a72..331a445 100644 --- a/tests/test_mail_profile_helpers.py +++ b/tests/test_mail_profile_helpers.py @@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase): self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap") self.assertNotIn("imap-secret", repr(audit.call_args.kwargs)) + def test_apply_transport_update_persists_standard_folder_mappings(self): + profile = SimpleNamespace( + id="profile-folders", + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + smtp_config={"host": "smtp.example.org"}, + smtp_username=None, + smtp_password_encrypted=None, + smtp_transport_revision="smtp-before", + imap_config={"host": "imap.example.org", "sent_folder": "Legacy Sent"}, + imap_username=None, + imap_password_encrypted=None, + imap_transport_revision="imap-before", + ) + session = SimpleNamespace(flush=lambda: None) + + with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index"): + _apply_profile_transport_update( + session, # type: ignore[arg-type] + profile, + user_id="user-1", + api_key_id=None, + smtp=None, + imap=ImapConfig( + host="imap.example.org", + folder_mappings={ + "inbox": "INBOX", + "sent": "Sent Items", + "drafts": "Drafts", + }, + ), + clear_imap=False, + ) + + self.assertEqual(profile.imap_config["sent_folder"], "Sent Items") + self.assertEqual( + profile.imap_config["folder_mappings"], + { + "inbox": "INBOX", + "sent": "Sent Items", + "drafts": "Drafts", + }, + ) + def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self): profile = SimpleNamespace( id="profile-1", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index a997c9e..5880fde 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -22,11 +22,17 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/); assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/); assert.match(profiles, /disabledReason=\{policySaveBlocker\}/); assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/); +assert.match(profiles, /folder_mappings: draft\.imapFolderMappings/); +assert.match(profiles, /listMailProfileImapFolders[\s\S]*listImapFolders/); +assert.match(profiles, /onLookupImapFolders=\{\(\) => void runImapFolderLookup\(\)\}/); +assert.match(profiles, /imapFolderLookupResult=\{imapFolderResult\}/); assert.match(mailbox, /ActionBlockerHint/); assert.match(mailbox, /DocumentationHelpLink/); assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/); assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/); +assert.match(mailbox, /folder_mappings\?\.inbox/); +assert.match(mailbox, /detected_folder_mappings\?\.inbox/); assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/); assert.match(bounces, /DocumentationHelpLink/); diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 9861a27..1da5378 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui"; +import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui"; import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react"; import type { ApiSettings } from "../../types"; import { @@ -11,6 +11,8 @@ import { deactivateMailServerProfile, fetchMailSettingsDelta, getMailProfilePolicy, + listImapFolders, + listMailProfileImapFolders, mailProfilePatternKeys, mailProfilePolicyLimitKeys, listAvailableMailCredentials, @@ -102,6 +104,7 @@ type ProfileDraft = { imapUsername: string; imapPassword: string; imapSentFolder: string; + imapFolderMappings: MailImapFolderMappings; imapTimeout: string; }; @@ -1163,7 +1166,8 @@ function ProfileForm({ const { translateText } = usePlatformLanguage(); const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); - const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null); + const [imapFolderResult, setImapFolderResult] = useState(null); + const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null); const disabled = busy || !canWrite; const credentialDisabled = disabled || !canManageCredentials; @@ -1256,6 +1260,7 @@ function ProfileForm({ useEffect(() => { setSmtpTestResult(null); setImapTestResult(null); + setImapFolderResult(null); setMailActionState(null); }, [editing, editTarget]); @@ -1276,6 +1281,7 @@ function ProfileForm({ imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort, imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity, imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder, + imapFolderMappings: patch.folder_mappings !== undefined ? normalizeMailImapFolderMappings(patch.folder_mappings) : draft.imapFolderMappings, imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout }); } @@ -1325,6 +1331,21 @@ function ProfileForm({ } } + async function runImapFolderLookup() { + if (!draftHasImap) return; + setMailActionState("folders"); + setImapFolderResult(null); + try { + setImapFolderResult(useSavedImapTest && existingProfile ? + await listMailProfileImapFolders(settings, existingProfile.id, selectedServerId, selectedCredentialId) : + await listImapFolders(settings, rawImapPayload(draft, false))); + } catch (err) { + setImapFolderResult({ ok: false, protocol: "imap", message: errorMessage(err), folders: [] }); + } finally { + setMailActionState(null); + } + } + return (
{!canManageCredentials && ( @@ -1472,7 +1493,7 @@ function ProfileForm({ {showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) && void runSmtpTest()} onTestImap={() => void runImapTest()} + onLookupImapFolders={() => void runImapFolderLookup()} smtpTestResult={smtpTestResult} imapTestResult={imapTestResult} + imapFolderLookupResult={imapFolderResult} initialSection={initialSection} visibleSections={visibleSections} mode={settingsPanelMode} /> @@ -1645,6 +1668,7 @@ function emptyProfileDraft(): ProfileDraft { imapUsername: "", imapPassword: "", imapSentFolder: "auto", + imapFolderMappings: {}, imapTimeout: "30" }; } @@ -1696,7 +1720,11 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge ? targetUsername : stringValue(profile.credentials?.imap?.username ?? profile.imap?.username), imapPassword: "", - imapSentFolder: stringValue("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto"), + imapSentFolder: stringValue(imapConfig?.folder_mappings?.sent || ("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto")), + imapFolderMappings: normalizeMailImapFolderMappings({ + ...(imapConfig?.folder_mappings ?? {}), + ...(!imapConfig?.folder_mappings?.sent && imapConfig?.sent_folder && imapConfig.sent_folder !== "auto" ? { sent: imapConfig.sent_folder } : {}) + }), imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30) }; } @@ -1873,7 +1901,7 @@ function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload { function imapServerPayload(draft: ProfileDraft): MailImapTestPayload { return mailImapSettingsPayload( - { host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }, + { host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout }, { fallbackSecurity: "tls", allowedSecurity: securityOptions } ); } diff --git a/webui/src/features/mail/MailboxPage.tsx b/webui/src/features/mail/MailboxPage.tsx index 10b08d6..4dbe450 100644 --- a/webui/src/features/mail/MailboxPage.tsx +++ b/webui/src/features/mail/MailboxPage.tsx @@ -177,6 +177,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings; async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) { if (!profileId) return; + const profileInbox = profiles.find((profile) => profile.id === profileId)?.imap?.folder_mappings?.inbox || ""; + const requestedFolder = foldersLoadedForProfile === profileId && selectedFolder + ? selectedFolder + : profileInbox || "INBOX"; const folderRequestId = ++folderRequestRef.current; const messageRequestId = ++messageListRequestRef.current; messageDetailRequestRef.current += 1; @@ -194,7 +198,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings; setDetailError(""); setError(""); try { - const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh); + const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh); if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return; if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e"); const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }]; @@ -202,7 +206,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings; const total = response.messages.total_count ?? loadedMessages.length; let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX"; if (!loadedFolders.some((folder) => folder.name === nextFolder)) { - nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX"; + const detectedInbox = response.folders.detected_folder_mappings?.inbox; + nextFolder = detectedInbox && loadedFolders.some((folder) => folder.name === detectedInbox) + ? detectedInbox + : loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX"; } const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder); const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);