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
+2
View File
@@ -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",
+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]:
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)
+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(
id="mail.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).",
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(
+1
View File
@@ -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
+51 -20
View File
@@ -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,
)
@@ -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),