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