feat(mail): map standard IMAP folders per profile

This commit is contained in:
2026-08-19 21:27:42 +02:00
parent 393331574a
commit e0e00d7000
13 changed files with 243 additions and 33 deletions
+11 -2
View File
@@ -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 non-secret connection metadata. Passwords are write-only encrypted values and
are never returned through list/read/capability responses. 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. Profiles may be scoped to system, tenant, user, group, or campaign context.
Scope controls where a profile can be discovered; effective policy can narrow Scope controls where a profile can be discovered; effective policy can narrow
that further. A visible profile is not automatically authorized for every 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. `mail:profile:write` remains broad profile administration authority.
1. Choose the narrowest suitable scope and a stable, descriptive name/slug. 1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder 2. Configure SMTP, optional IMAP, TLS mode, account identity, standard folder
behavior, and timeouts. Sender/envelope/recipient constraints belong to 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. effective Mail policy; Campaign rate limits remain delivery configuration.
3. Enter credentials only in the dedicated credential fields. Returned profile 3. Enter credentials only in the dedicated credential fields. Returned profile
data indicates whether credentials are configured without returning them. data indicates whether credentials are configured without returning them.
+2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from govoplan_core.mail.config import ( from govoplan_core.mail.config import (
ImapConfig, ImapConfig,
ImapFolderMappings,
ImapServerConfig, ImapServerConfig,
SmtpConfig, SmtpConfig,
SmtpServerConfig, SmtpServerConfig,
@@ -13,6 +14,7 @@ from govoplan_core.mail.config import (
__all__ = [ __all__ = [
"ImapConfig", "ImapConfig",
"ImapFolderMappings",
"ImapServerConfig", "ImapServerConfig",
"SmtpConfig", "SmtpConfig",
"SmtpServerConfig", "SmtpServerConfig",
+1 -1
View File
@@ -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]: 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 username_was_supplied = "username" in config.model_fields_set
password_was_supplied = "password" in config.model_fields_set password_was_supplied = "password" in config.model_fields_set
username = payload.pop("username", None) username = payload.pop("username", None)
+38
View File
@@ -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( DocumentationTopic(
id="mail.bounce-processing", id="mail.bounce-processing",
title="Delivery-status and bounce processing", title="Delivery-status and bounce processing",
+4 -2
View File
@@ -1179,6 +1179,7 @@ def _mailbox_folder_response(
message=f"Found {len(folders)} IMAP folder(s).", message=f"Found {len(folders)} IMAP folder(s).",
folders=folders, folders=folders,
detected_sent_folder=result.detected_sent_folder, detected_sent_folder=result.detected_sent_folder,
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
from_cache=from_cache, from_cache=from_cache,
refreshing=refreshing, refreshing=refreshing,
indexed_at=indexed_at, indexed_at=indexed_at,
@@ -2276,7 +2277,7 @@ def update_profile(
session, session,
profile=profile, profile=profile,
protocol="smtp", protocol="smtp",
config=smtp_config.model_dump(mode="json"), config=smtp_config.model_dump(mode="json", exclude_none=True),
user_id=principal.user.id, user_id=principal.user.id,
) )
imap_server = None imap_server = None
@@ -2286,7 +2287,7 @@ def update_profile(
profile=profile, profile=profile,
protocol="imap", protocol="imap",
config=( 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 if imap_config is not None and not payload.clear_imap
else None else None
), ),
@@ -3063,6 +3064,7 @@ def list_imap_folder_settings(
message=f"Found {len(folders)} IMAP folder(s).", message=f"Found {len(folders)} IMAP folder(s).",
folders=folders, folders=folders,
detected_sent_folder=result.detected_sent_folder, detected_sent_folder=result.detected_sent_folder,
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
) )
except Exception as exc: except Exception as exc:
return MailImapFolderListResponse( return MailImapFolderListResponse(
+1
View File
@@ -370,6 +370,7 @@ class MailImapFolderListResponse(BaseModel):
message: str message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list) folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None detected_sent_folder: str | None = None
detected_folder_mappings: dict[str, str] = Field(default_factory=dict)
from_cache: bool = False from_cache: bool = False
refreshing: bool = False refreshing: bool = False
indexed_at: datetime | None = None indexed_at: datetime | None = None
+51 -20
View File
@@ -103,6 +103,7 @@ class ImapFolderListResult:
security: str security: str
folders: list[ImapMailboxInfo] folders: list[ImapMailboxInfo]
detected_sent_folder: str | None = None detected_sent_folder: str | None = None
detected_folder_mappings: dict[str, str] | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -317,25 +318,47 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
return None return None
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None: _STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = {
for name, flags in parsed: "inbox": ("\\inbox",),
if "\\sent" in flags or "\\sentmail" in flags: "sent": ("\\sent", "\\sentmail"),
return name "drafts": ("\\drafts",),
"trash": ("\\trash",),
"archive": ("\\archive", "\\all"),
"junk": ("\\junk", "\\spam"),
}
common_names = [ _STANDARD_FOLDER_NAMES: dict[str, tuple[str, ...]] = {
"Sent", "inbox": ("INBOX", "Posteingang"),
"Sent Items", "sent": ("Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent"),
"Sent Messages", "drafts": ("Drafts", "Entwürfe", "Entwuerfe"),
"Gesendet", "trash": ("Trash", "Deleted Items", "Gelöscht", "Geloescht", "Papierkorb"),
"Gesendete Elemente", "archive": ("Archive", "Archives", "Archiv"),
"INBOX.Sent", "junk": ("Junk", "Spam", "Junk Email", "Unerwünscht", "Unerwuenscht"),
"INBOX/Sent", }
]
names = {name.lower(): name for name, _ in parsed}
for candidate in common_names: def _detect_standard_folder_mappings(parsed: list[tuple[str, set[str]]]) -> dict[str, str]:
if candidate.lower() in names: detected: dict[str, str] = {}
return names[candidate.lower()] for role, accepted_flags in _STANDARD_FOLDER_FLAGS.items():
return None 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: 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"]) name = str(item["name"])
count = sum(1 for record in records if _mock_folder_matches(record, 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)) 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( return ImapFolderListResult(
host=host, host=host,
port=port, port=port,
security=imap_config.security.value, security=imap_config.security.value,
folders=folders, 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)) folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
detected = _detect_standard_folder_mappings(parsed)
return ImapFolderListResult( return ImapFolderListResult(
host=host, host=host,
port=port, port=port,
security=security, security=security,
folders=folders, folders=folders,
detected_sent_folder=_detect_sent_folder(parsed), detected_sent_folder=detected.get("sent"),
detected_folder_mappings=detected,
) )
@@ -358,7 +358,7 @@ def initialize_profile_hierarchy(
for protocol, value in (("smtp", smtp), ("imap", imap)): for protocol, value in (("smtp", smtp), ("imap", imap)):
if value is None or protocol in existing: if value is None or protocol in existing:
continue 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 = { credentials = {
"username": raw.pop("username", None), "username": raw.pop("username", None),
"password": raw.pop("password", None), "password": raw.pop("password", None),
+41
View File
@@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.sending.imap import ( from govoplan_mail.backend.sending.imap import (
ImapAppendError, ImapAppendError,
ImapConfigurationError, ImapConfigurationError,
_detect_standard_folder_mappings,
_detect_sent_folder, _detect_sent_folder,
_extract_mailbox_name, _extract_mailbox_name,
_fetch_message_by_uid, _fetch_message_by_uid,
@@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
_select_readonly, _select_readonly,
_sequence_set, _sequence_set,
append_message_to_sent, append_message_to_sent,
list_imap_folders,
list_imap_messages, list_imap_messages,
list_imap_uids_since, list_imap_uids_since,
) )
@@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase):
"Gesendet", "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): class ImapMessagePaginationTests(unittest.TestCase):
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self): def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
+45
View File
@@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap") self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs)) 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): def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
profile = SimpleNamespace( profile = SimpleNamespace(
id="profile-1", id="profile-1",
@@ -22,11 +22,17 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/);
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/); assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/); assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/); 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, /ActionBlockerHint/);
assert.match(mailbox, /DocumentationHelpLink/); assert.match(mailbox, /DocumentationHelpLink/);
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/); assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/); 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(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
assert.match(bounces, /DocumentationHelpLink/); assert.match(bounces, /DocumentationHelpLink/);
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; 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 { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
@@ -11,6 +11,8 @@ import {
deactivateMailServerProfile, deactivateMailServerProfile,
fetchMailSettingsDelta, fetchMailSettingsDelta,
getMailProfilePolicy, getMailProfilePolicy,
listImapFolders,
listMailProfileImapFolders,
mailProfilePatternKeys, mailProfilePatternKeys,
mailProfilePolicyLimitKeys, mailProfilePolicyLimitKeys,
listAvailableMailCredentials, listAvailableMailCredentials,
@@ -102,6 +104,7 @@ type ProfileDraft = {
imapUsername: string; imapUsername: string;
imapPassword: string; imapPassword: string;
imapSentFolder: string; imapSentFolder: string;
imapFolderMappings: MailImapFolderMappings;
imapTimeout: string; imapTimeout: string;
}; };
@@ -1163,7 +1166,8 @@ function ProfileForm({
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null); const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null); const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null); const [imapFolderResult, setImapFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const disabled = busy || !canWrite; const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials; const credentialDisabled = disabled || !canManageCredentials;
@@ -1256,6 +1260,7 @@ function ProfileForm({
useEffect(() => { useEffect(() => {
setSmtpTestResult(null); setSmtpTestResult(null);
setImapTestResult(null); setImapTestResult(null);
setImapFolderResult(null);
setMailActionState(null); setMailActionState(null);
}, [editing, editTarget]); }, [editing, editTarget]);
@@ -1276,6 +1281,7 @@ function ProfileForm({
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort, imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity, imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder, 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 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 ( return (
<div className="mail-profile-form"> <div className="mail-profile-form">
{!canManageCredentials && ( {!canManageCredentials && (
@@ -1472,7 +1493,7 @@ function ProfileForm({
{showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) && {showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) &&
<MailServerSettingsPanel <MailServerSettingsPanel
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }} smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }} imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout }}
smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }} smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }}
imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }} imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }}
onSmtpChange={patchSmtpSettings} onSmtpChange={patchSmtpSettings}
@@ -1501,8 +1522,10 @@ function ProfileForm({
busyAction={mailActionState} busyAction={mailActionState}
onTestSmtp={() => void runSmtpTest()} onTestSmtp={() => void runSmtpTest()}
onTestImap={() => void runImapTest()} onTestImap={() => void runImapTest()}
onLookupImapFolders={() => void runImapFolderLookup()}
smtpTestResult={smtpTestResult} smtpTestResult={smtpTestResult}
imapTestResult={imapTestResult} imapTestResult={imapTestResult}
imapFolderLookupResult={imapFolderResult}
initialSection={initialSection} initialSection={initialSection}
visibleSections={visibleSections} visibleSections={visibleSections}
mode={settingsPanelMode} /> mode={settingsPanelMode} />
@@ -1645,6 +1668,7 @@ function emptyProfileDraft(): ProfileDraft {
imapUsername: "", imapUsername: "",
imapPassword: "", imapPassword: "",
imapSentFolder: "auto", imapSentFolder: "auto",
imapFolderMappings: {},
imapTimeout: "30" imapTimeout: "30"
}; };
} }
@@ -1696,7 +1720,11 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
? targetUsername ? targetUsername
: stringValue(profile.credentials?.imap?.username ?? profile.imap?.username), : stringValue(profile.credentials?.imap?.username ?? profile.imap?.username),
imapPassword: "", 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) imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
}; };
} }
@@ -1873,7 +1901,7 @@ function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
function imapServerPayload(draft: ProfileDraft): MailImapTestPayload { function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
return mailImapSettingsPayload<MailSecurity>( return mailImapSettingsPayload<MailSecurity>(
{ 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 } { fallbackSecurity: "tls", allowedSecurity: securityOptions }
); );
} }
+9 -2
View File
@@ -177,6 +177,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) { async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
if (!profileId) return; 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 folderRequestId = ++folderRequestRef.current;
const messageRequestId = ++messageListRequestRef.current; const messageRequestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1; messageDetailRequestRef.current += 1;
@@ -194,7 +198,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setDetailError(""); setDetailError("");
setError(""); setError("");
try { 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 (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"); 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: [] }]; 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; const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX"; let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
if (!loadedFolders.some((folder) => folder.name === nextFolder)) { 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 foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize); const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);