feat(mail): map standard IMAP folders per profile
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user