From 480c18c67cc345b462ec9dd0dde252fbfc13f571 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:32:44 +0200 Subject: [PATCH] Release govoplan-mail v0.1.27: stabilize credentials, folder encoding and transport progress --- docs/INTERFACE_PATTERN_MIGRATION.md | 19 +- docs/MAIL_HANDBOOK.md | 180 ++++- package-lock.json | 6 +- package.json | 4 +- pyproject.toml | 4 +- src/govoplan_mail/backend/capabilities.py | 110 ++- src/govoplan_mail/backend/documentation.py | 8 +- .../german_structured_documentation.py | 3 + src/govoplan_mail/backend/mail_profiles.py | 17 +- src/govoplan_mail/backend/manifest.py | 80 +- src/govoplan_mail/backend/sending/imap.py | 463 ++++++++++-- tests/test_campaign_imap_batch.py | 233 ++++++ tests/test_campaign_protocol_authorization.py | 187 +++++ tests/test_documentation.py | 43 +- tests/test_imap_batch.py | 231 ++++++ tests/test_imap_mailbox_encoding.py | 225 ++++++ tests/test_mail_profile_helpers.py | 40 +- webui/package-lock.json | 6 +- webui/package.json | 4 +- .../test-interface-pattern-language.mjs | 32 +- webui/src/api/mail.ts | 28 +- .../features/mail/MailProfileManagement.tsx | 676 +---------------- .../features/mail/MailProfilePolicyEditor.tsx | 712 ++++++++++++++++++ webui/src/features/mail/MailboxPage.tsx | 409 +++++++--- .../features/mail/mailReferenceProviders.ts | 15 +- webui/src/i18n/generatedTranslations.ts | 54 +- webui/src/styles/mail-profiles.css | 91 +-- 27 files changed, 2875 insertions(+), 1005 deletions(-) create mode 100644 tests/test_campaign_imap_batch.py create mode 100644 tests/test_campaign_protocol_authorization.py create mode 100644 tests/test_imap_batch.py create mode 100644 tests/test_imap_mailbox_encoding.py create mode 100644 webui/src/features/mail/MailProfilePolicyEditor.tsx diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index b840d1d..54d056d 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -9,7 +9,7 @@ mailbox, policy, and delivery-evidence consequences described here. | Surface | Primary task | Archetype | Consequence | Pattern evidence | | --- | --- | --- | --- | --- | | `/mail` folder, message, and preview panes | Browse and inspect an authorized mailbox without changing provider state | Directory/explorer | Medium because message metadata and content are private, although navigation is read-only | Full-height three-pane workspace, bounded paging, stable keyboard selection, contextual Help Center link, explicit no-profile blocker | -| `/mail` toolbar, page filter, and pagination | Select a profile, refresh bounded indexes, and find a message on the current page | Explorer actions and local filtering | Low for refresh; medium for provider access | Shared actions expose loading/profile/folder blockers; profile transport summary is non-secret; loading and errors use Core components | +| `/mail` toolbar, page filter, and pagination | Select a profile, refresh the current bounded mailbox context, and find a message | Explorer actions and local filtering | Low for refresh; medium for provider access | Persistent `WorkspaceFrame`/`WorkspaceActionBar` keeps one right-aligned Reload; shared `FormField` profile selection; labelled Mailbox tools `Dialog` groups targeted refreshes and related diagnostics through `FormSection`; loading/profile/folder/permission blockers remain visible | | System/tenant/group/user/campaign profile surfaces | Compare profiles, protocol servers, reusable credentials, status, and scope | Administration/configuration | High because endpoints, credentials, and inheritance control external communication | Shared `ConnectionTree`, stable row actions, textual status, permission/target blockers, and contextual admin help | | Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons | | Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help | @@ -42,6 +42,15 @@ mailbox, policy, and delivery-evidence consequences described here. copy distinguishes retained reusable credentials from scrubbed owned secrets. - Mailbox browsing is read-only. Listing or previewing must not mark messages read, move, delete, reply, or expose unbounded content. +- The mailbox Reload rechecks authorized profiles and refreshes the current + catalogue, bounded page, and still-selected message. IMAP retains its page; + JMAP refresh starts a new cursor chain while keeping the search. Selecting a + synthetic grouping refreshes only the catalogue. Refresh failures retain + usable data and a retry; request identities reject previous-profile/tenant + results. Folder expansion is independent of labels and refreshes. Advanced + targeted reads and bounce diagnostics remain in the labelled tools dialog, + not a second persistent toolbar. Escape in that dialog leaves message + selection intact. ## Accessibility, responsive, and privacy evidence @@ -52,8 +61,14 @@ reasons are keyboard-focusable. Status always has text in addition to color. Contextual links identify their destination to assistive technology. Profile/policy grids collapse to one column below 900 px. The mailbox changes -from three panes to two below 1250 px and to a single-column toolbar and message +from three panes to two below 1280 px and to single-column message rows below 760 px while preserving source order and independent scroll regions. +Core owns toolbar wrapping, with Reload right-aligned even on narrow screens. +The two-row mailbox layout shares the available height between list and preview +instead of reserving a fixed preview minimum that can squeeze message rows to +zero height. Narrow screens stack compact folders, a usable message index, and +the preview within a vertically scrollable bounded workspace. The action +header never scrolls away, and each pane retains its own bounded scroll region. Long identities and transport summaries wrap or ellipsize inside stable bounds. Profile and mailbox APIs return non-secret transport metadata and bounded diff --git a/docs/MAIL_HANDBOOK.md b/docs/MAIL_HANDBOOK.md index 23ce01b..e30dea1 100644 --- a/docs/MAIL_HANDBOOK.md +++ b/docs/MAIL_HANDBOOK.md @@ -114,6 +114,16 @@ profile/server. Empty roles retain automatic behavior. The historical for compatibility; a Campaign-specific Sent override still wins for that Campaign. +Use readable Unicode names such as `Entwürfe`, not IMAP wire encodings such as +`Entw&APw-rfe`. Discovery decodes modified UTF-7 before detecting standard +folder roles; SELECT, STATUS, and Sent APPEND encode and quote the chosen name +for the active connection. Literal ampersands, quotes, backslashes, and Unicode +characters round-trip without renaming remote folders. Previously saved +wire-form names are resolved against that account's live folder list; if the +same string is also an actual readable folder name, the readable name wins. +Rediscovery and an explicit profile save replace old encoded configuration +values with readable names; background reads never rewrite configuration. + 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 @@ -191,6 +201,19 @@ non-production provider and mailbox first. A successful connection test does not prove policy authorization for a later Campaign context, deliverability, recipient acceptance, SPF/DKIM/DMARC alignment, or future availability. +Campaign runtime authorization follows the protocol being used: SMTP batch and +single-message calls enforce the explicit SMTP credential policy, while +append-to-Sent enforces the IMAP credential policy. A valid SMTP call does not +need to carry an unrelated IMAP credential just because the profile supports +both protocols. Full campaign authoring validation and complete profile +summaries continue to require both configured selections when their policies +forbid inherited credentials. The selected protocol's missing credential, +inactive/unauthorized binding or stale transport revision still stops the +operation before decryption or provider contact. Resolving a policy rejection +never requires disabling TLS or weakening either credential policy. Correcting +this runtime check does not change stored configuration or approved builds, +reset job state, or retry/send messages automatically. + Testing a saved profile requires both `mail:profile:test` and `mail:profile:use`, and the profile must be active. Profile creation or test authority alone is not enough. @@ -201,10 +224,46 @@ ordinary consumers to bypass reusable profiles. ### Read a mailbox +The persistent workspace header contains the profile selector, **Mailbox tools**, +Help, and one right-aligned **Reload**. These controls remain available when the +mailbox is empty or no usable profile is configured. Reload rechecks the permitted +profiles and refreshes the current folder catalogue and bounded message page; +it also rereads a selected message if that message remains on the page. IMAP +keeps the page offset. JMAP starts a fresh cursor chain at page one while keeping +the search term. An unavailable profile is replaced only by another currently +authorized active profile, or the explicit no-profile state. + +Folder icons expand or collapse; labels select. Synthetic grouping labels select +the group without reading a nonexistent provider folder, and Reload in that +state refreshes only the folder catalogue. Refreshes keep user-controlled +expansion. A failed refresh preserves usable loaded data, shows the error, and +leaves Reload available for retry; late responses from an earlier profile or +tenant cannot replace the current context. +Mailbox context reads bypass browser response/promise reuse so an immediate +Reload really rechecks permissions and state. Mail's bounded server-side index +is unchanged; its explicit refresh flag and live/cached provenance still apply. +Failed pagination restores the page and page-size labels belonging to retained +rows. Dismissing or changing the preview while Reload is pending takes +precedence over its remembered selection. +On narrow screens, scroll vertically through folders, message list, and preview +within the mailbox workspace; the profile/tools/Reload header remains visible. + +**Mailbox tools** groups the occasional profile-only, folder-only, and +message-only refreshes in a dialog, separate from **Bounce status**. Bounce +status requires `mail:bounce:read` or `mail:bounce:manage`; without either it +remains visible and disabled with an explanation. Escape closes this dialog +without clearing the selected message. These read controls do not grant profile +administration, send SMTP messages, APPEND messages, or change mailbox flags. + The current mailbox UI and API are read-only. An authorized user can list IMAP or JMAP folders, page through messages, and inspect a bounded full message. -IMAP folder names are parsed and quoted defensively; Sent-folder discovery uses -provider flags and common names. JMAP discovers the Session and Mail account, +IMAP folder names are decoded for display and encoded and quoted defensively +for mailbox commands; Sent-folder discovery uses provider flags and common +readable names. The default IMAP4rev1 mode uses modified UTF-7. A connection +that has explicitly enabled `UTF8=ACCEPT` uses UTF-8 instead; merely advertising +that capability does not change encoding. Invalid provider encodings produce +an explicit error rather than a replacement name that could address another +folder. Refresh any already-loaded folder list after upgrading. JMAP discovers the Session and Mail account, uses `Mailbox/get` hierarchy and roles, runs text search with `Email/query`, and uses `Email/get` for bounded summaries/details. `Email/changes` exposes a bounded incremental cursor; an expired state tells the caller to perform a full @@ -214,6 +273,9 @@ page came directly from the provider, from the bounded mailbox index, or from an index while a refresh is in progress, including the index timestamp when available. +The mailbox-name boundary follows [RFC 3501 section 5.1.3](https://www.rfc-editor.org/rfc/rfc3501.html#section-5.1.3) +and, only after explicit activation, [RFC 6855 section 3](https://www.rfc-editor.org/rfc/rfc6855.html#section-3). + Message HTML is displayed only in the shared sandboxed message component. Remote URLs and active markup are removed, embedded `data:`/`cid:` image references remain isolated, and plain text is always available when supplied. @@ -340,6 +402,19 @@ account's user scope. Grant `mail:profile:write_own` for self-service; An update that omits a password preserves the current encrypted password. A credential replacement never depends on reading the old cleartext value back. +The shared credential editor resolves Mail server restrictions from the +authorized metadata catalogue when it opens. Names appear as loading completes; +no page refresh is required, and typing in a draft does not reload the catalogue. +Closing and reopening refreshes the available servers and can retry a temporary +metadata failure. Inactive servers remain labelled inactive; deleted or +unauthorized references remain visible as unavailable and are never silently +removed from the credential. Labels do not grant permission to use a server, +and the lookup does not retrieve secrets. +If saving a reusable credential fails, the editor shows the error beside the +unchanged draft. Retry explicitly after correcting the cause. Saving disables +editing and closing until the request finishes; a failed save never silently +discards a replacement secret that has not been stored. + ### Delete a profile Profile deletion is immediate for Mail-owned secrets and audit evidence: @@ -368,13 +443,32 @@ Effective policy is contextual. Administrators should document: - allowed and denied SMTP/IMAP/JMAP hosts; - permitted From, envelope sender (including bounce address), and envelope recipient-domain patterns; -- whether SMTP/IMAP credentials inherit from the reusable profile; and +- whether SMTP/IMAP may use a default credential or require an explicit Mail-owned credential selection; and - which lower-level settings are locked by a parent policy. -Campaign delivery requires reusable profile credentials. A legacy policy that -requires campaign-local credentials fails closed with guidance to store them on -the Mail profile and enable effective inheritance. This preserves compatibility -of the policy model without reopening a consumer-owned secret store. +In **Mail profile policy → Credential selection**, SMTP and IMAP have separate +controls. **Allow profile default credential** (`inherit: true`) permits either +the selected server's default credential or an explicit authorized Mail-owned +credential. **Require explicit Mail credential** (`inherit: false`) requires a +server and credential reference in Campaign Mail settings. Neither option +permits campaign-local passwords or copies a secret into Campaign. + +**Inherit policy from parent** leaves the local value unset; it is different +from allowing a server's default credential. System policy always has a concrete +choice. Other scopes show the local choice alongside the saved effective result +and policy path. **Allow override** controls +`allow_lower_level_limits["smtp_credentials.inherit"]` and the equivalent IMAP +key. It is not a separate `allow_override` field in the credential object. A +parent's explicit-credential requirement may be changed by a child only while +that parent allows overrides. Locked fields and their override controls remain +read-only; a lower scope cannot unlock them. Campaign policy has no lower-level +override controls. Editing any policy still requires that scope's policy-write +permission and an unlocked workflow. + +Policy saves retain their draft after a failed write and require an explicit +retry. If the policy was saved but a dependent screen refresh fails, the editor +reports that the policy was saved and advises reloading the display; it does not +report a failed save or repeat the accepted write. Policy reads are available through system/tenant/context routes to suitably authorized actors. Adaptive Docs exposes a safe explanation of the effective @@ -451,6 +545,78 @@ treated as an unknown provider mutation. confirmed absence records verified recovery and permits only a new, deliberate attempt identifier. +### Bounded IMAP append batches / Begrenzte IMAP-Ablagestapel + +Campaign's bulk Sent-folder operation can use the optional +`mail.campaign_delivery.campaign_imap_batch(tenant_id=..., campaign_id=...)` +context. Opening this context has no provider effect. Mail opens a connection +only after an individual message passes its current authorization, selected +IMAP credential policy, both frozen transport revisions and durable recovery +checks. Subsequent messages reuse that authenticated connection and its detected +Sent folder, including the provider's original Unicode mailbox wire encoding. +Authorization and credential resolution are performed for every message, not +cached. Changing the authorized profile, selected references, folder or resolved +credentials releases the previous connection before the next APPEND. + +Each message still receives one sequential APPEND and its own recovery evidence. +There is no parallel APPEND, MULTIAPPEND, automatic SMTP resend or replay after +APPEND starts. A lost APPEND reply remains outcome-unknown and requires explicit +mailbox reconciliation. Failures while connecting, before any APPEND, may use a +bounded reconnect; rejected authentication is not retried. A failure to finalize +accepted recovery evidence closes the batch. Cleanup/logout failure does not +turn an accepted APPEND into a failed one. Session state is scoped to the current +batch, never shared across tenants or campaigns, and is released on exit. + +Deployment controls apply to batch connection reuse, not campaign authorization: + +| Environment variable | Default | Range / effect | +| --- | --- | --- | +| `GOVOPLAN_IMAP_BATCH_REUSE` | `true` | `false`, `0`, `no` or `off` disables reuse. | +| `GOVOPLAN_IMAP_BATCH_MAX_MESSAGES` | `100` | 1–10,000 successful APPENDs per connection. | +| `GOVOPLAN_IMAP_BATCH_MAX_AGE_SECONDS` | `300` | 1–3,600 seconds; rotate before the next message, not during an APPEND. | +| `GOVOPLAN_IMAP_BATCH_IDLE_HEALTH_CHECK_SECONDS` | `30` | 0–3,600 seconds idle before a NOOP; 0 checks every reuse. | +| `GOVOPLAN_IMAP_BATCH_RECONNECT_ATTEMPTS` | `1` | 0–5 extra connection attempts, only before APPEND. | + +Invalid numeric values use defaults; out-of-range numbers are clamped. An active +session keeps the policy with which it was created. Existing single-message +callers retain one connection per call and no automatic connection retries. +Older Mail capabilities without this optional context keep the single-message +behavior. Safe outcome fields include connection sequence, session reuse and +reconnect count; they contain no hosts, credentials, provider responses or MIME +content. Fewer logins and folder discoveries improve connection overhead, not +the provider's intrinsic per-message APPEND or durable-evidence latency. + +Deutsch: Die Sammelablage im Gesendet-Ordner kann eine begrenzte authentifizierte +IMAP-Verbindung wiederverwenden. Der Stapelkontext allein verbindet sich nicht. +Vor jeder Nachricht prüft Mail erneut Berechtigung, IMAP-Zugangsdatenrichtlinie, +beide eingefrorenen Transportrevisionen und Wiederherstellungsnachweise; die +Zugangsdaten werden weiterhin je Nachricht aufgelöst. Profil, ausgewählte +Referenzen, Ordner oder aufgelöste Zugangsdaten dürfen nicht stillschweigend von +einer älteren Verbindung übernommen werden. Ordnererkennung und ursprüngliche +Provider-Kodierung bleiben ausschließlich an dieselbe Verbindung gebunden. + +Jede Nachricht erhält weiterhin einen einzelnen, sequenziellen APPEND und einen +eigenen Nachweis. Es gibt kein paralleles APPEND, kein MULTIAPPEND und keine +automatische Wiederholung nach Beginn von APPEND. Ein unbekanntes Ergebnis muss +am Postfach abgeglichen werden; eine fehlende Gesendet-Kopie darf keinen erneuten +SMTP-Versand auslösen. Nur Verbindungsaufbau vor APPEND darf begrenzt wiederholt +werden, nicht eine abgelehnte Anmeldung. Ein Fehler beim Abschluss des +Wiederherstellungsnachweises schließt den Stapel; ein reiner Abmeldefehler macht +eine bestätigte Ablage nicht rückgängig. Mandanten und Kampagnen teilen keine +Stapelverbindung. + +Die obigen Betriebsvariablen bedeuten standardmäßig: Wiederverwendung aktiv, +höchstens 100 Nachrichten bzw. 300 Sekunden pro Verbindung, NOOP nach 30 Sekunden +Leerlauf und höchstens einen zusätzlichen Verbindungsversuch vor APPEND. Der +Wechsel erfolgt vor der nächsten Nachricht, niemals mitten im APPEND. `0` beim +Leerlaufintervall prüft jede Wiederverwendung; deaktivierte Wiederverwendung +verwendet weiterhin einzelne APPENDs. Ungültige Zahlen verwenden den Standard, +Zahlen außerhalb des Wertebereichs werden begrenzt. Eine aktive Verbindung +behält ihre beim Aufbau gelesene Richtlinie. Einzelaufrufe und ältere optionale +Mail-Verträge bleiben kompatibel. Verbindungszähler enthalten keine Zugangsdaten +oder Providerdetails. Die Optimierung spart Verbindungsaufbau und Ordnersuche; +Provider-Ablage und dauerhafte Einzelnachweise benötigen weiterhin ihre Zeit. + ### Delivery-status and calendar-reply sources An authorized Mail bounce source scans a bounded IMAP UID range without diff --git a/package-lock.json b/package-lock.json index f73d501..7a929ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.26", + "version": "0.1.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/mail-webui", - "version": "0.1.26", + "version": "0.1.27", "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/package.json b/package.json index c954906..330cfcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.26", + "version": "0.1.27", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -19,7 +19,7 @@ "LICENSE" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/pyproject.toml b/pyproject.toml index a481bbc..6756f68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-mail" -version = "0.1.26" +version = "0.1.27" description = "GovOPlaN mail module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.42", + "govoplan-core>=0.1.45", "pydantic>=2,<3", "redis>=5,<6", "SQLAlchemy>=2,<3", diff --git a/src/govoplan_mail/backend/capabilities.py b/src/govoplan_mail/backend/capabilities.py index 419e650..47321ce 100644 --- a/src/govoplan_mail/backend/capabilities.py +++ b/src/govoplan_mail/backend/capabilities.py @@ -5,12 +5,14 @@ from contextvars import ContextVar from dataclasses import dataclass from email.message import EmailMessage from email.utils import formatdate, make_msgid +from threading import get_ident from typing import Any, Iterator from sqlalchemy.orm import Session from govoplan_core.core.mail import NotificationMailDeliveryRequest from govoplan_core.core.modules import ModuleContext +from govoplan_mail.backend.config import ImapConfig from govoplan_mail.backend.mail_profiles import ( MailProfileError, _assert_campaign_inherits_profile_credentials, @@ -38,6 +40,7 @@ from govoplan_mail.backend.recovery import ( ) from govoplan_mail.backend.sending.imap import ( ImapAppendError, + ImapBatchSession, ImapConfigurationError, append_message_to_sent, ) @@ -72,6 +75,78 @@ class CampaignSmtpDeliveryResult: @dataclass(frozen=True, slots=True) class CampaignImapAppendResult: folder: str + connection_sequence: int = 1 + session_reused: bool = False + reconnect_count: int = 0 + + +class CampaignImapBatchState: + """Lazy transport reuse; authorization and recovery remain per message.""" + + def __init__(self, *, tenant_id: str, campaign_id: str): + self.tenant_id = tenant_id + self.campaign_id = campaign_id + self._session: ImapBatchSession | None = None + self._binding: tuple[Any, ...] | None = None + self._previous_connections = 0 + self._previous_reconnects = 0 + self._closed = False + self._owner_thread = get_ident() + + @property + def connection_count(self) -> int: + return self._previous_connections + (self._session.connection_count if self._session else 0) + + @property + def reconnect_count(self) -> int: + return self._previous_reconnects + (self._session.reconnect_count if self._session else 0) + + def assert_scope(self, *, tenant_id: str, campaign_id: str) -> None: + if ( + self._closed or get_ident() != self._owner_thread + or tenant_id != self.tenant_id or campaign_id != self.campaign_id + ): + raise ImapConfigurationError("The IMAP batch does not match this campaign scope") + + def session_for(self, config: ImapConfig, *, binding: tuple[Any, ...]) -> ImapBatchSession: + if self._closed: + raise ImapConfigurationError("The IMAP batch is closed") + if self._session is not None and ( + self._binding != binding or not self._session.matches_config(config) + ): + self._release_session() + if self._session is None: + self._session = ImapBatchSession(config) + self._binding = binding + return self._session + + def _release_session(self) -> None: + if self._session is not None: + self._previous_connections += self._session.connection_count + self._previous_reconnects += self._session.reconnect_count + self._session.close() + self._session = None + + def close(self) -> None: + self._closed = True + self._release_session() + + +_ACTIVE_IMAP_BATCH: ContextVar[CampaignImapBatchState | None] = ContextVar( + "govoplan_mail_active_imap_batch", default=None, +) + + +@contextmanager +def campaign_imap_batch(*, tenant_id: str, campaign_id: str) -> Iterator[CampaignImapBatchState]: + """Open no connection until an individual append passes its current checks.""" + state = CampaignImapBatchState(tenant_id=tenant_id, campaign_id=campaign_id) + token = _ACTIVE_IMAP_BATCH.set(state) + try: + yield state + finally: + _ACTIVE_IMAP_BATCH.reset(token) + state.close() @dataclass(frozen=True, slots=True) @@ -155,6 +230,7 @@ def _authorized_campaign_profile( campaign_id: str, profile_id: str, selection: dict[str, str | None] | None = None, + credential_protocol: str | None = None, ): profile = ensure_mail_profile_allowed_for_campaign( session, @@ -164,7 +240,7 @@ def _authorized_campaign_profile( require_active=True, ) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) - _assert_campaign_inherits_profile_credentials(profile, policy, selection) + _assert_campaign_inherits_profile_credentials(profile, policy, selection, protocol=credential_protocol) return profile @@ -343,6 +419,7 @@ def campaign_smtp_batch( campaign_id=campaign_id, profile_id=profile_id, selection=selection, + credential_protocol="smtp", ) except MailProfileError: raise @@ -444,6 +521,7 @@ def send_campaign_email_bytes( campaign_id=campaign_id, profile_id=profile_id, selection=selection, + credential_protocol="smtp", ) except MailProfileError: raise @@ -593,6 +671,9 @@ def append_campaign_message_to_sent( recovery_resource_type: str | None = None, recovery_resource_id: str | None = None, ) -> CampaignImapAppendResult: + batch = _ACTIVE_IMAP_BATCH.get() + if batch is not None: + batch.assert_scope(tenant_id=tenant_id, campaign_id=campaign_id) selection = _selection_payload( profile_id=profile_id, smtp_server_id=smtp_server_id, @@ -607,6 +688,7 @@ def append_campaign_message_to_sent( campaign_id=campaign_id, profile_id=profile_id, selection=selection, + credential_protocol="imap", ) except MailProfileError: raise @@ -681,6 +763,15 @@ def append_campaign_message_to_sent( ) except MailProfileError: raise MailProfileError("Appending to Sent is blocked by the effective Mail policy.") from None + batch_session = None + if batch is not None: + batch_session = batch.session_for( + imap, + binding=( + tenant_id, campaign_id, profile_id, smtp_server_id, smtp_credential_id, + imap_server_id, imap_credential_id, smtp_revision, imap_revision, folder, + ), + ) try: recovery = begin_provider_effect_recovery( kind="imap-append", @@ -701,7 +792,12 @@ def append_campaign_message_to_sent( outcome_unknown=True, ) try: - result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder) + if batch_session is None: + result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder) + else: + result = append_message_to_sent( + message_bytes, imap_config=imap, folder=folder, batch_session=batch_session, + ) except ImapAppendError as exc: sanitized = _sanitized_imap_error(exc) if recovery is not None: @@ -728,11 +824,18 @@ def append_campaign_message_to_sent( try: recovery.succeed_imap(folder=result.folder) except Exception: + if batch is not None: + batch.close() raise ImapAppendError( "IMAP APPEND returned success, but durable recovery evidence could not be finalized.", outcome_unknown=True, ) from None - return CampaignImapAppendResult(folder=result.folder) + return CampaignImapAppendResult( + folder=result.folder, + connection_sequence=batch.connection_count if batch else getattr(result, "connection_sequence", 1), + session_reused=getattr(result, "session_reused", False), + reconnect_count=batch.reconnect_count if batch else getattr(result, "reconnect_count", 0), + ) class MailCampaignCapability: @@ -745,6 +848,7 @@ class MailCampaignCapability: mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json) campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary) campaign_smtp_batch = staticmethod(campaign_smtp_batch) + campaign_imap_batch = staticmethod(campaign_imap_batch) send_campaign_email_bytes = staticmethod(send_campaign_email_bytes) append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent) wait_for_rate_limit = staticmethod(wait_for_rate_limit) diff --git a/src/govoplan_mail/backend/documentation.py b/src/govoplan_mail/backend/documentation.py index b8354f0..f1cb062 100644 --- a/src/govoplan_mail/backend/documentation.py +++ b/src/govoplan_mail/backend/documentation.py @@ -517,9 +517,11 @@ def _credential_line(policy: dict[str, Any]) -> str: smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True)) imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True)) return ( - f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; " - f"IMAP {'inherits' if imap_inherit else 'requires local credentials'}. " - "Campaign delivery is available only for protocols that inherit credentials from the selected Mail profile." + f"Credential selection: SMTP {'allows a profile default or explicit Mail credential' if smtp_inherit else 'requires an explicit Mail credential'}; " + f"IMAP {'allows a profile default or explicit Mail credential' if imap_inherit else 'requires an explicit Mail credential'}. " + "Select the authorized server and credential in Campaign Mail settings when explicit selection is required. " + "Secrets remain in Mail. Policy administrators configure each protocol under Credential selection; " + "ancestor lower-level locks cannot be overridden." ) diff --git a/src/govoplan_mail/backend/german_structured_documentation.py b/src/govoplan_mail/backend/german_structured_documentation.py index 3bf50bf..44b3b0c 100644 --- a/src/govoplan_mail/backend/german_structured_documentation.py +++ b/src/govoplan_mail/backend/german_structured_documentation.py @@ -197,6 +197,9 @@ GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'mail.bounce-proces 'seine mailbox lesen.'], 'steps': ['Öffnen Sie Mail und wählen Sie ein autorisiertes IMAP- ' 'oder JMAP-fähiges Profil.', + 'Neuladen rechts aktualisiert den gesamten aktuellen Kontext; ' + 'Postfachwerkzeuge enthält gezielte Aktualisierungen und ' + 'berechtigungsabhängige Rückläuferdiagnosen.', 'Wählen Sie einen Ordner aus, überprüfen Sie das ' 'Live/Cache-Synchronisationslabel und stellen Sie den ' 'begrenzten Nachrichtenindex auf die Seite; JMAP-Suchen ' diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index 8afe6d0..7d5bede 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -1430,19 +1430,26 @@ def _assert_campaign_inherits_profile_credentials( profile: MailServerProfile, policy: EffectiveMailProfilePolicy, selection: Mapping[str, str | None] | None = None, + *, + protocol: str | None = None, ) -> None: - for protocol in ("smtp", "imap"): - if not _profile_has_transport(profile, protocol): + # Authoring and complete transport summaries validate both protocols. + # An effect capability can require only the protocol it actually receives + # and uses; an SMTP call does not carry the campaign's IMAP selection. + if protocol is not None and protocol not in {"smtp", "imap"}: + raise MailProfileError("Credential policy protocol must be smtp or imap") + for selected_protocol in ((protocol,) if protocol is not None else ("smtp", "imap")): + if protocol is None and not _profile_has_transport(profile, selected_protocol): continue explicit_credential = ( selection or {} - ).get(f"{protocol}_credential_id") + ).get(f"{selected_protocol}_credential_id") if ( - not _credential_policy_for_protocol(policy, protocol).inherit + not _credential_policy_for_protocol(policy, selected_protocol).inherit and not explicit_credential ): raise MailProfileError( - f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} " + f"Campaign delivery cannot use the selected profile because the effective {selected_protocol.upper()} " "credential policy requires an explicit credential selection for this campaign." ) diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index b690aff..171fcf7 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -450,7 +450,7 @@ POP3_PROVIDER = ExternalProviderDeclaration( manifest = ModuleManifest( id="mail", name="Mail", - version="0.1.26", + version="0.1.27", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"), provides_interfaces=( @@ -1059,7 +1059,7 @@ manifest = ModuleManifest( id="mail.profiles-and-policy", title="Mail profiles and policy hierarchy", summary="Mail sending and mailbox access use reusable SMTP/IMAP/JMAP profiles governed by an effective system, tenant, owner, and campaign policy.", - body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. SMTP, IMAP, and JMAP host allow/deny rules are inherited independently. JMAP Session discovery fails closed when its advertised API origin differs unless an administrator explicitly allows that origin. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.", + body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. SMTP, IMAP, and JMAP host allow/deny rules are inherited independently. JMAP Session discovery fails closed when its advertised API origin differs unless an administrator explicitly allows that origin. Runtime documentation adds the current tenant posture when the actor may read mail profile policy. In the Mail profile policy editor, Credential selection separately controls SMTP and IMAP: allow a profile default or require an explicit authorized Mail-owned server and credential reference in Campaign Mail settings. Both choices keep secrets in Mail. Inherit policy leaves the local choice unset and follows the parent; it is not the same as allowing default credentials. System values are concrete, while lower scopes show local, effective, and source-path values. Allow override sets the matching allow_lower_level_limits protocol key; an ancestor lock cannot be changed or re-enabled below that scope. Campaign policy has no lower-level override controls. Scope-write permission and an unlocked workflow remain required. A failed write preserves the draft for explicit retry; a successful write followed by a dependent refresh failure remains saved and asks only to reload the display.", layer="configured", documentation_types=("admin",), audience=("tenant_admin", "mail_admin", "campaign_admin"), @@ -1090,7 +1090,17 @@ manifest = ModuleManifest( "oder ob auf Personen-, Gruppen- und Campaign-Ebene zusätzliche wiederverwendbare Profile angelegt werden können. " "Host-Freigaben und -Sperren für SMTP, IMAP und JMAP werden unabhängig vererbt. Ein von der JMAP-Session " "angekündigter API-Ursprung muss bei abweichendem Ursprung ausdrücklich freigegeben sein. " - "Die Laufzeitdokumentation ergänzt die aktuelle Lage des Mandanten, wenn die handelnde Person die Mail-Profilrichtlinie lesen darf." + "Die Laufzeitdokumentation ergänzt die aktuelle Lage des Mandanten, wenn die handelnde Person die Mail-Profilrichtlinie lesen darf. " + "Im Mail-Profilrichtlinieneditor steuert Auswahl der Zugangsdaten SMTP und IMAP getrennt: Standard-Zugangsdaten " + "des Profils zulassen oder ausdrückliche berechtigte Mail-Server- und Zugangsdatenverweise in den Mail-Einstellungen " + "der Kampagne verlangen. Geheimnisse bleiben bei beiden Optionen in Mail. Richtlinie erben lässt den lokalen Wert " + "offen und übernimmt die übergeordnete Entscheidung; dies ist nicht dasselbe wie Standard-Zugangsdaten zu erlauben. " + "Systemwerte sind konkret; darunter werden lokale und wirksame Werte samt Richtlinienpfad angezeigt. Überschreiben " + "zulassen setzt den passenden Protokollschlüssel in allow_lower_level_limits. Eine übergeordnete Sperre kann darunter " + "weder geändert noch aufgehoben werden. Kampagnenrichtlinien haben keine Freigabe für weitere untere Bereiche. " + "Schreibberechtigung für den jeweiligen Bereich und ein entsperrter Arbeitsablauf bleiben erforderlich. Ein fehlgeschlagener " + "Schreibvorgang erhält den Entwurf für einen ausdrücklichen Wiederholungsversuch. Scheitert nach erfolgreichem Speichern " + "nur die Aktualisierung abhängiger Daten, bleibt die Richtlinie gespeichert; lediglich die Anzeige muss neu geladen werden." ), } }, @@ -1111,7 +1121,8 @@ manifest = ModuleManifest( 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." + "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. " + "Enter readable Unicode names such as Entwürfe, not modified UTF-7 wire names such as Entw&APw-rfe. Discovery decodes names before detecting folder roles; mailbox reads, status checks, and Sent APPEND encode and quote them for the active connection. Previously saved wire names are resolved against the same account's live folder list, with an exact readable-name match taking precedence over a legacy alias. Rediscover and save to replace old encoded configuration values explicitly; reads do not rewrite profiles. Invalid provider encodings fail explicitly instead of silently addressing a replacement folder." ), layer="configured", documentation_types=("admin", "user"), @@ -1140,7 +1151,13 @@ manifest = ModuleManifest( "und gespeichert, während das Kompatibilitätsfeld für ältere Verbraucher synchron bleibt. Eine Campaign-spezifische " "Abweichung für den Gesendet-Ordner bleibt für diese Campaign maßgeblich und wird von der Profilerkennung nicht verändert. " "Die Erkennung listet nur die beim Anbieter sichtbaren Namen auf und legt keine externen Ordner an, benennt sie nicht um, " - "verschiebt sie nicht und löscht sie nicht." + "verschiebt sie nicht und löscht sie nicht. Lesbare Unicode-Namen wie Entwürfe eingeben, nicht die " + "Modified-UTF-7-Übertragungsform Entw&APw-rfe. Die Erkennung dekodiert Namen vor der Rollenzuordnung; " + "Postfachzugriffe, Statusabfragen und Gesendet-APPEND kodieren und maskieren sie für die aktive Verbindung. " + "Früher gespeicherte Übertragungsformen werden anhand der aktuellen Ordnerliste desselben Kontos aufgelöst; " + "ein exakt passender lesbarer Name hat Vorrang vor einem alten Alias. Erneute Erkennung und ausdrückliches " + "Speichern ersetzen alte kodierte Konfigurationswerte; Lesezugriffe schreiben Profile nicht um. Fehlerhafte " + "Anbieterkodierungen führen zu einer klaren Fehlermeldung statt unbemerkt einen Ersatzordner anzusprechen." ), } }, @@ -1213,7 +1230,7 @@ manifest = ModuleManifest( id="mail.profile-ownership-and-consumers", title="Mail owns transport profiles and credentials", summary="Other modules select authorized Mail profiles by stable identifier; they do not copy SMTP/IMAP settings or secrets.", - body="Mail encrypts credentials, tests connections, evaluates profile scope and policy, and performs revision-gated transport effects without returning resolved configuration. Random persisted revisions rotate when normalized transport or account identity changes, while password-only rotation remains transparent to built business intent. Campaign stores only server.mail_profile_id plus sanitized delivery evidence. Campaign-local transport fields are rejected, and legacy campaign records fail closed until an explicit profile migration preserves the source audit record and updates an editable version.", + body="Mail encrypts credentials, tests connections, evaluates profile scope and policy, and performs revision-gated transport effects without returning resolved configuration. Random persisted revisions rotate when normalized transport or account identity changes, while password-only rotation remains transparent to built business intent. Campaign stores only server.mail_profile_id plus sanitized delivery evidence. Campaign-local transport fields are rejected, and legacy campaign records fail closed until an explicit profile migration preserves the source audit record and updates an editable version. The shared credential editor resolves server names from authorized Mail metadata on opening, without a page refresh or reading secrets. Typing does not reload this catalogue; reopening refreshes it and retries temporary metadata failures. Inactive servers retain their status, and deleted or unauthorized selected references stay visible as unavailable rather than being removed. A displayed label never grants server-use permission.", layer="available", documentation_types=("admin", "user"), audience=("mail_user", "mail_admin", "campaign_manager", "campaign_sender"), @@ -1247,7 +1264,12 @@ manifest = ModuleManifest( "Passwortrotation bleibt für bereits aufgebauten fachlichen Willen transparent. Campaign speichert nur " "server.mail_profile_id und bereinigte Zustellnachweise. Campaign-lokale Transportfelder werden abgelehnt; " "ältere Campaign-Datensätze bleiben gesperrt, bis eine ausdrückliche Profilmigration den ursprünglichen " - "Auditdatensatz bewahrt und eine bearbeitbare Version aktualisiert." + "Auditdatensatz bewahrt und eine bearbeitbare Version aktualisiert. Der gemeinsame Zugangsdateneditor " + "löst Servernamen beim Öffnen aus berechtigten Mail-Metadaten auf, ohne die Seite neu zu laden oder " + "Geheimnisse auszulesen. Beim Tippen wird dieser Katalog nicht erneut geladen; erneutes Öffnen aktualisiert " + "ihn und wiederholt vorübergehend fehlgeschlagene Metadatenabfragen. Inaktive Server behalten ihre " + "Kennzeichnung. Gelöschte oder nicht berechtigte ausgewählte Verweise bleiben als nicht verfügbar sichtbar " + "und werden nicht entfernt. Eine angezeigte Bezeichnung erteilt niemals die Berechtigung zur Servernutzung." ), } }, @@ -1323,10 +1345,14 @@ manifest = ModuleManifest( id="mail.workflow.read-mailbox", title="Read a permitted mailbox without changing it", summary="Choose an IMAP- or JMAP-enabled profile, browse folders, search or page messages, and inspect bounded content through the read-only mailbox surface.", - body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. IMAP retains its existing bounded list behavior. JMAP adds capability discovery, server-side text search, query-state cursors, and bounded Email/changes synchronization; an expired state explicitly requires a full refresh. Lists expose provider read/unread flags and provenance without mutating them. Message HTML is isolated and sanitized, while attachment metadata, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.", + body=( + "Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. IMAP retains its existing bounded list behavior. IMAP folder names appear as readable Unicode, including umlauts and literal ampersands, and are encoded for the active connection when opened. Refresh an already-loaded folder list after an upgrade; folder discovery does not rename remote folders. Folder icons expand or collapse the tree; labels select without changing expansion. Selecting a synthetic parent grouping highlights that group and clears message selection without opening an invented provider folder. JMAP adds capability discovery, server-side text search, query-state cursors, and bounded Email/changes synchronization; an expired state explicitly requires a full refresh. Lists expose provider read/unread flags and provenance without mutating them. Message HTML is isolated and sanitized, while attachment metadata, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content. " + "The persistent workspace header keeps the profile selector, Mailbox tools, Help, and one right-aligned Reload available even without a profile or message selection. Reload first rechecks authorized profiles, then refreshes the current folder catalogue and bounded message page together; a selected message is reread only if still present. IMAP retains the current page; JMAP starts a fresh cursor chain on page one while retaining the search. A selected synthetic grouping refreshes only the folder catalogue, not an invented mailbox. Folder expansion is retained. Failed refreshes preserve usable loaded data with an explicit error and retry action, never present failure as an empty mailbox, and ignore late reads from a previous profile or tenant. Mailbox tools groups the optional profile-only, folder-only, and message-only refreshes separately from Bounce status. The latter remains visible with a permission explanation when bounce-read/manage authority is absent. These controls do not grant profile administration rights, run SMTP delivery, append mail, or change read/unread flags. " + "Mailbox context reads bypass browser response reuse; bounded server-side indexes and their provenance remain governed by Mail. A failed pagination request restores the page and size that belong to the retained rows. Dismissing or changing the preview while Reload is pending takes precedence over its remembered selection. On narrow screens, scroll vertically through folders, messages, and preview inside the mailbox workspace; its action header remains visible." + ), layer="configured", - documentation_types=("user",), - audience=("mail_user",), + documentation_types=("user", "admin"), + audience=("mail_user", "mail_admin"), order=42, conditions=( DocumentationCondition( @@ -1345,12 +1371,29 @@ manifest = ModuleManifest( "summary": "Ein IMAP- oder JMAP-fähiges Profil auswählen, Ordner durchsuchen und begrenzte Nachrichteninhalte in der nur lesbaren Postfachoberfläche prüfen.", "body": ( "Der Postfachzugriff erfordert sowohl Leseberechtigung für das Postfach als auch Nutzungsberechtigung für ein " - "im Bereich der handelnden Person sichtbares Profil. IMAP behält sein bisheriges Verhalten; JMAP ergänzt " + "im Bereich der handelnden Person sichtbares Profil. IMAP zeigt lesbare Unicode-Ordnernamen mit Umlauten " + "und kaufmännischen Und-Zeichen; beim Öffnen werden sie für die aktive Verbindung kodiert. Eine bereits " + "geladene Ordnerliste nach einem Update neu laden; die Erkennung benennt keine externen Ordner um. " + "Ordnersymbole klappen den Baum auf oder zu; Beschriftungen wählen aus, ohne die Aufklappstellung zu ändern. " + "Eine künstliche übergeordnete Gruppe wird hervorgehoben und leert die Nachrichtenauswahl, ohne einen erfundenen Anbieterordner zu öffnen. " + "IMAP behält sein bisheriges Verhalten; JMAP ergänzt " "serverseitige Suche, zustandsgebundene Seitennavigation und begrenzte inkrementelle Änderungen. Listen zeigen die vom Anbieter gelieferten Gelesen-/Ungelesen-Kennzeichen " "und die Herkunft aus Livezugriff, Zwischenspeicher oder Aktualisierung, ohne diese Zustände zu verändern. HTML-Inhalte " "werden isoliert und bereinigt; Anlagen, Inline-Verweise, nicht verfügbare Inhalte und Providerfehler bleiben ausdrücklich sichtbar. " "Das Auflisten von Ordnern oder Nachrichten darf keine Nachricht als gelesen markieren, verschieben oder löschen und keine " - "unbegrenzten Inhalte offenlegen." + "unbegrenzten Inhalte offenlegen. " + "Die dauerhafte Arbeitsbereichsleiste enthält Profilauswahl, Postfachwerkzeuge, Hilfe und genau einmal Neuladen rechts, auch ohne Profil- oder Nachrichtenauswahl. " + "Neuladen prüft zuerst die berechtigten Profile und aktualisiert anschließend den aktuellen Ordnerkatalog und die begrenzte Nachrichtenseite gemeinsam. " + "Eine noch vorhandene ausgewählte Nachricht wird erneut gelesen. IMAP behält die Seite; JMAP beginnt mit der bestehenden Suche eine neue Cursorfolge auf Seite eins. " + "Bei einer ausgewählten künstlichen Gruppe wird nur der Ordnerkatalog gelesen, kein erfundenes Postfach. Aufklappstellungen bleiben erhalten. " + "Fehlgeschlagene Aktualisierungen erhalten nutzbare geladene Daten mit ausdrücklicher Fehlermeldung und Wiederholungsmöglichkeit; sie erscheinen nicht als leeres Postfach. " + "Verspätete Antworten eines vorherigen Profils oder Mandanten werden verworfen. Postfachwerkzeuge trennt gezieltes Aktualisieren von Profilen, Ordnern und Nachrichten vom Rückläuferstatus. " + "Bei fehlender Rückläufer-Lese- oder Verwaltungsberechtigung bleibt dieser Einstieg mit Erklärung sichtbar und deaktiviert. " + "Diese Aktionen vergeben keine Profilverwaltungsrechte, versenden oder hängen keine Nachrichten an und ändern keine Gelesen-/Ungelesen-Kennzeichen. " + "Postfachkontext-Lesezugriffe umgehen die Wiederverwendung von Browserantworten; begrenzte serverseitige Indizes und deren Herkunft bleiben unter Kontrolle von Mail. " + "Fehlgeschlagene Seitenwechsel stellen die zu den erhaltenen Zeilen gehörige Seite und Seitengröße wieder her. " + "Das Schließen oder Wechseln der Vorschau während Neuladen hat Vorrang vor der zuvor gemerkten Auswahl. " + "Auf schmalen Bildschirmen werden Ordner, Nachrichten und Vorschau innerhalb des Postfacharbeitsbereichs vertikal gescrollt; seine Aktionsleiste bleibt sichtbar." ), } }, @@ -1358,13 +1401,14 @@ manifest = ModuleManifest( "kind": "workflow", "route": "/mail", "screen": "Mail", - "help_contexts": ["mail.list", "mail.mailbox"], + "help_contexts": ["mail.list", "mail.mailbox", "mail.mailbox.reload", "mail.mailbox.tools"], "prerequisites": [ "An active visible profile has IMAP or JMAP configured.", "You may both use that profile and read its mailbox.", ], "steps": [ "Open Mail and choose an authorized IMAP- or JMAP-enabled profile.", + "Use the right-aligned Reload for the complete current context; Mailbox tools contains optional targeted refreshes and permission-aware bounce diagnostics.", "Select a folder, review the live/cached synchronization label, and page its bounded message index; JMAP searches run at the provider.", "Use the provider-derived read/unread indicator, then open only the message needed for the task.", "Switch between safe plain-text and isolated HTML views as needed, and review attachment or unavailable-content details before closing the preview.", @@ -1431,7 +1475,11 @@ manifest = ModuleManifest( id="mail.reference.campaign-delivery-contract", title="Integrate Campaign through the Mail delivery contract", summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.", - body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Synchronous batches authorize the complete recipient set and preflight DNS, egress, connectivity, TLS, and authentication before the first effect. Mail reuses the bounded connection when deployment policy permits, health-checks it before reuse, and reconnects before the next message when a stale connection is detected. A connection loss after DATA begins remains outcome-unknown and is never replayed. Systemic authentication, sender, or connectivity failures carry stable reason codes so Campaign pauses remaining queued work and shows connection, reconnect, failure, and pause progress. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution.", + body=( + "The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Synchronous batches authorize the complete recipient set and preflight DNS, egress, connectivity, TLS, and authentication before the first effect. Mail reuses the bounded connection when deployment policy permits, health-checks it before reuse, and reconnects before the next message when a stale connection is detected. A connection loss after DATA begins remains outcome-unknown and is never replayed. Systemic authentication, sender, or connectivity failures carry stable reason codes so Campaign pauses remaining queued work and shows connection, reconnect, failure, and pause progress. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution." + " Runtime credential-selection checks are protocol-scoped: SMTP batch and single-message delivery enforce SMTP policy; Sent-folder append enforces IMAP policy. A valid explicit SMTP selection must not fail merely because the SMTP call does not carry an unrelated explicit IMAP credential, and vice versa. Full campaign authoring validation and complete profile summaries still check both configured protocols. Missing explicit credentials for the selected protocol, inactive or unauthorized bindings and stale transport revisions remain blocked before credential decryption or provider contact. A successful Mail connection test proves only that selected connection/login, not later campaign authorization or recipient acceptance. Correcting this runtime check changes no saved policy, credentials, build/review evidence, or delivery status and does not send messages automatically." + " The optional campaign_imap_batch context reuses a bounded authenticated IMAP connection for sequential APPENDs, never MULTIAPPEND or parallel effects. It opens lazily after each message's current authorization, both frozen revisions, IMAP-only credential resolution and recovery checks; permissions and recovery evidence are never cached. Connection-local Sent-folder discovery preserves the provider's original Unicode wire names. Different campaign/tenant scopes are rejected, and changed profile, references, folder or resolved credentials cannot reuse the prior connection. Defaults are 100 messages or 300 seconds per connection, an idle NOOP after 30 seconds, and one extra connection attempt before APPEND. GOVOPLAN_IMAP_BATCH_REUSE can disable reuse; MAX_MESSAGES, MAX_AGE_SECONDS, IDLE_HEALTH_CHECK_SECONDS and RECONNECT_ATTEMPTS with the same prefix bound deployment behavior as documented in the handbook. There is no automatic APPEND replay after transmission starts; unknown outcomes require mailbox reconciliation without SMTP resend. Accepted recovery-evidence finalization failure closes the batch; logout failure does not reverse acceptance. Per-message connection/reuse/reconnect counters expose no secrets. Older optional Mail providers retain single-message behavior." + ), layer="available", documentation_types=("admin", "user"), audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"), @@ -1467,6 +1515,8 @@ manifest = ModuleManifest( "Wiederzustellung; unbekannte Ergebnisse erfordern ausdrückliche Abstimmung, und rohe Empfängerablehnungen erfordern Mail-Diagnoseberechtigung. " "Mail-Ausgangsverarbeitung und Aufbewahrungsläufe werden nach Mandantenberechtigung partitioniert. Das Deaktivieren von Mail lässt bereits " "angenommene Befehle und Nachweise daher zur Klärung durch den Betrieb unverändert bestehen." + " Zur Laufzeit gelten Zugangsdaten-Auswahlregeln protokollbezogen: SMTP-Stapel und einzelne SMTP-Zustellungen prüfen die SMTP-Richtlinie; die Ablage im Gesendet-Ordner prüft die IMAP-Richtlinie. Eine gültige ausdrückliche SMTP-Auswahl darf nicht daran scheitern, dass der reine SMTP-Aufruf keine getrennte IMAP-Zugangsdatenkennung übergibt; umgekehrt gilt dasselbe. Vollständige Kampagnenvalidierung und Profilzusammenfassungen prüfen weiterhin beide konfigurierten Protokolle. Fehlende ausdrückliche Zugangsdaten des genutzten Protokolls, inaktive oder unberechtigte Bindungen und veraltete Transportrevisionen bleiben vor Entschlüsselung oder Providerkontakt gesperrt. Ein erfolgreicher Mail-Verbindungstest belegt nur diese Verbindung und Anmeldung, nicht spätere Kampagnenberechtigung oder Empfängerannahme. Die Korrektur verändert keine gespeicherte Richtlinie, Zugangsdaten, Build-/Prüfnachweise oder Zustellzustände und versendet nicht automatisch." + " Der optionale Kontext campaign_imap_batch verwendet eine begrenzte authentifizierte IMAP-Verbindung für sequenzielle APPENDs erneut, niemals MULTIAPPEND oder parallele Wirkungen. Er öffnet erst nach der aktuellen Berechtigungsprüfung, Prüfung beider eingefrorenen Revisionen, alleiniger Auflösung der IMAP-Zugangsdaten und Wiederherstellungsprüfung jeder Nachricht; Berechtigungen und Nachweise werden nicht zwischengespeichert. Die verbindungslokale Gesendet-Ordner-Erkennung erhält die ursprünglichen Unicode-Übertragungsnamen des Providers. Andere Mandanten oder Kampagnen werden abgelehnt; geänderte Profile, Referenzen, Ordner oder aufgelöste Zugangsdaten dürfen die bisherige Verbindung nicht weiterverwenden. Standardwerte sind 100 Nachrichten oder 300 Sekunden je Verbindung, NOOP nach 30 Sekunden Leerlauf und ein zusätzlicher Verbindungsversuch vor APPEND. GOVOPLAN_IMAP_BATCH_REUSE kann die Wiederverwendung deaktivieren; MAX_MESSAGES, MAX_AGE_SECONDS, IDLE_HEALTH_CHECK_SECONDS und RECONNECT_ATTEMPTS mit demselben Präfix begrenzen das Betriebsverhalten gemäß Handbuch. Nach Übertragungsbeginn wird APPEND niemals automatisch wiederholt; unbekannte Ergebnisse erfordern Postfachabgleich ohne erneuten SMTP-Versand. Ein Fehler beim Abschluss des Annahmenachweises schließt den Stapel; ein Abmeldefehler macht die Annahme nicht rückgängig. Verbindungs-, Wiederverwendungs- und Neuverbindungszähler enthalten keine Geheimnisse. Ältere optionale Mail-Anbieter behalten das Einzelaufrufverhalten." ), } }, @@ -1475,7 +1525,7 @@ manifest = ModuleManifest( "route": "/campaigns/{campaign_id}/mail-settings", "screen": "Campaign Mail settings", "section": "Mail-owned profile and transport boundary", - "verification": "Prove stale revisions fail before credential decryption, batch preflight fails before DATA, two messages reuse one healthy connection, a stale connection reconnects before the next message, post-DATA disconnect is never replayed, systemic failures pause remaining jobs, provider details are sanitized, and the interface/version gate passes.", + "verification": "Prove stale revisions fail before credential decryption, batch preflight fails before DATA, two messages reuse one healthy connection, a stale connection reconnects before the next message, post-DATA disconnect is never replayed, systemic failures pause remaining jobs, provider details are sanitized, and the interface/version gate passes. For IMAP, prove one login and folder discovery for sequential APPENDs, count/age rotation, changed authorization or revisions block the next message before decryption, per-message recovery remains independent, unknown APPEND is never replayed, and nested/scoped contexts clean up safely.", "related_topic_ids": [ "mail.profile-ownership-and-consumers", "campaigns.mail-profile-user-journey", diff --git a/src/govoplan_mail/backend/sending/imap.py b/src/govoplan_mail/backend/sending/imap.py index a83feb8..afca06c 100644 --- a/src/govoplan_mail/backend/sending/imap.py +++ b/src/govoplan_mail/backend/sending/imap.py @@ -1,16 +1,21 @@ from __future__ import annotations +import base64 +import binascii import imaplib import logging +import os import re import socket import ssl import time +from contextlib import contextmanager from dataclasses import dataclass from email import policy from email.message import EmailMessage from email.parser import BytesParser -from typing import Any +from threading import Lock +from typing import Any, Iterator from govoplan_core.security.outbound_http import ( OutboundHttpError, @@ -212,6 +217,9 @@ class ImapAppendResult: folder: str bytes_appended: int response: str | None = None + connection_sequence: int = 1 + session_reused: bool = False + reconnect_count: int = 0 def _require_imap_config(config: ImapConfig) -> tuple[str, int]: @@ -276,7 +284,75 @@ def _unquote_imap_token(value: str) -> str: return value -def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None: +def _encode_mailbox_name(name: str) -> str: + """Encode a Unicode mailbox using RFC 3501 section 5.1.3 modified UTF-7.""" + + result: list[str] = [] + pending: list[str] = [] + + def flush() -> None: + if pending: + encoded = base64.b64encode("".join(pending).encode("utf-16-be")) + result.append("&" + encoded.decode("ascii").rstrip("=").replace("/", ",") + "-") + pending.clear() + + for char in name: + if " " <= char <= "~": + flush() + result.append("&-" if char == "&" else char) + else: + pending.append(char) + flush() + return "".join(result) + + +def _decode_mailbox_name(name: str, *, utf8_enabled: bool = False) -> str: + """Decode mailbox names only, never message bodies or arbitrary IMAP text. + + UTF8=ACCEPT changes mailbox names to UTF-8 (RFC 6855 section 3); an + advertised capability alone does not activate that mode. Invalid provider + names fail explicitly rather than silently selecting a replacement name. + """ + + if utf8_enabled: + return name + result: list[str] = [] + position = 0 + try: + name.encode("ascii") + while position < len(name): + if name[position] != "&": + result.append(name[position]) + position += 1 + continue + end = name.find("-", position + 1) + if end < 0: + raise ValueError("unterminated modified UTF-7 shift") + encoded = name[position + 1:end] + if not encoded: + result.append("&") + else: + if not re.fullmatch(r"[A-Za-z0-9+,]+", encoded): + raise ValueError("invalid modified UTF-7 alphabet") + raw = base64.b64decode(encoded.replace(",", "/") + "=" * (-len(encoded) % 4), validate=True) + decoded = raw.decode("utf-16-be") + if any(" " <= char <= "~" for char in decoded): + raise ValueError("modified UTF-7 encodes a printable ASCII character") + canonical = base64.b64encode(raw).decode("ascii").rstrip("=").replace("/", ",") + if canonical != encoded: + raise ValueError("non-canonical modified UTF-7 base64") + result.append(decoded) + position = end + 1 + except (ValueError, UnicodeError, binascii.Error) as exc: + raise ImapAppendError("IMAP server returned an invalid mailbox name encoding", temporary=False) from exc + return "".join(result) + + +def _extract_wire_mailbox_name( + list_response_line: bytes | str | tuple[bytes, bytes] | None, + *, + utf8_enabled: bool = False, +) -> tuple[str, set[str]] | None: r"""Best-effort parser for IMAP LIST response lines. RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox @@ -292,7 +368,18 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str blindly taking the last quoted value. """ - line = _decode_item(list_response_line).strip() + if list_response_line is None: + return None + literal = None + if isinstance(list_response_line, tuple): + list_response_line, literal = list_response_line + try: + line = ( + list_response_line.decode("utf-8" if utf8_enabled else "ascii") + if isinstance(list_response_line, bytes) else list_response_line + ).strip() + except UnicodeError as exc: + raise ImapAppendError("IMAP server returned an invalid mailbox name encoding", temporary=False) from exc match = re.match( r'^\((?P[^)]*)\)\s+' r'(?P"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+' @@ -303,6 +390,14 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str if match: flags = {part.lower() for part in match.group("flags").split()} mailbox = _unquote_imap_token(match.group("mailbox")) + if literal is not None: + literal_size = re.fullmatch(r"\{(\d+)\+?\}", match.group("mailbox")) + if not literal_size or int(literal_size.group(1)) != len(literal): + raise ImapAppendError("IMAP server returned an invalid mailbox name literal", temporary=False) + try: + mailbox = literal.decode("utf-8" if utf8_enabled else "ascii") + except UnicodeError as exc: + raise ImapAppendError("IMAP server returned an invalid mailbox name encoding", temporary=False) from exc if mailbox: return mailbox, flags return None @@ -318,6 +413,38 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str return None +def _extract_mailbox_name( + list_response_line: bytes | str | tuple[bytes, bytes] | None, + *, + utf8_enabled: bool = False, +) -> tuple[str, set[str]] | None: + extracted = _extract_wire_mailbox_name(list_response_line, utf8_enabled=utf8_enabled) + if extracted is None: + return None + name, flags = extracted + return _decode_mailbox_name(name, utf8_enabled=utf8_enabled), flags + + +def _parsed_mailbox_listing(client: imaplib.IMAP4, data: list[Any]) -> list[tuple[str, set[str]]]: + utf8_enabled = getattr(client, "utf8_enabled", False) is True + wire_names: dict[str, str] = {} + parsed: list[tuple[str, set[str]]] = [] + for item in data: + extracted = _extract_wire_mailbox_name(item, utf8_enabled=utf8_enabled) + if extracted is None: + continue + wire_name, flags = extracted + name = _decode_mailbox_name(wire_name, utf8_enabled=utf8_enabled) + if name in wire_names and wire_names[name] != wire_name: + raise ImapAppendError("IMAP server returned ambiguous mailbox name encodings", temporary=False) + wire_names[name] = wire_name + parsed.append((name, flags)) + # Connection-local only: never reuse names across users, profiles or modes. + client._govoplan_mailbox_names = wire_names # type: ignore[attr-defined] + client._govoplan_mailbox_names_utf8 = utf8_enabled # type: ignore[attr-defined] + return parsed + + _STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = { "inbox": ("\\inbox",), "sent": ("\\sent", "\\sentmail"), @@ -366,13 +493,7 @@ def discover_sent_folder(client: imaplib.IMAP4) -> str | None: if typ != "OK" or not data: return None - parsed: list[tuple[str, set[str]]] = [] - for item in data: - extracted = _extract_mailbox_name(item) - if extracted: - parsed.append(extracted) - - return _detect_sent_folder(parsed) + return _detect_sent_folder(_parsed_mailbox_listing(client, data)) def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str: @@ -452,14 +573,9 @@ def _list_imap_folders_on_client( if typ != "OK": raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True) - parsed: list[tuple[str, set[str]]] = [] + parsed = _parsed_mailbox_listing(client, data or []) folders: list[ImapMailboxInfo] = [] - for item in data or []: - extracted = _extract_mailbox_name(item) - if not extracted: - continue - name, flags = extracted - parsed.append((name, flags)) + for name, flags in parsed: message_count, unseen_count = ( (None, None) if not include_status or _has_folder_flag(flags, "noselect") @@ -622,13 +738,40 @@ def _has_folder_flag(flags: set[str], flag: str) -> bool: return any(item.casefold().lstrip("\\") == wanted for item in flags) -def _quote_mailbox_name(name: str) -> str: - return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" +def _quote_mailbox_name(name: str, *, client: imaplib.IMAP4 | None = None) -> str: + if any(ord(char) < 32 or 127 <= ord(char) <= 159 or char in "\u2028\u2029" for char in name): + raise ImapConfigurationError("IMAP mailbox names must not contain control characters") + utf8_enabled = getattr(client, "utf8_enabled", False) is True + wire_names = getattr(client, "_govoplan_mailbox_names", None) + if getattr(client, "_govoplan_mailbox_names_utf8", None) is not utf8_enabled: + wire_names = None + if ( + client is not None + and not utf8_enabled + and wire_names is None + and name.isascii() + and re.search(r"&[A-Za-z0-9+,]*-", name) + ): + # Older configurations saved LIST's wire representation. Resolve those + # against this authenticated connection, without guessing or rewriting + # configuration. A genuine literal name always wins an ambiguous alias. + typ, data = client.list() + if typ != "OK": + raise ImapAppendError("IMAP folder listing failed while resolving a saved folder name", temporary=True) + _parsed_mailbox_listing(client, data or []) + wire_names = client._govoplan_mailbox_names # type: ignore[attr-defined] + if isinstance(wire_names, dict) and name in wire_names: + wire_name = wire_names[name] + elif not utf8_enabled and isinstance(wire_names, dict) and name in wire_names.values(): + wire_name = name + else: + wire_name = name if utf8_enabled else _encode_mailbox_name(name) + return "\"" + wire_name.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]: try: - typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)") + typ, data = client.status(_quote_mailbox_name(folder, client=client), "(MESSAGES UNSEEN)") except Exception: return None, None if typ != "OK": @@ -787,7 +930,7 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) - def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]: - typ, data = client.select(_quote_mailbox_name(folder), readonly=True) + typ, data = client.select(_quote_mailbox_name(folder, client=client), readonly=True) if typ != "OK": raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) selected_count = _decode_item(data[0] if data else None).strip() @@ -1253,69 +1396,231 @@ def list_imap_uids_since( _log_imap_cleanup_failure("listing watcher UIDs", cleanup_exc) +def _batch_env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: + try: + value = int(os.environ.get(name, str(default))) + except ValueError: + return default + return min(maximum, max(minimum, value)) + + +@dataclass(frozen=True, slots=True) +class ImapBatchPolicy: + reuse_connections: bool = True + max_messages_per_connection: int = 100 + max_connection_age_seconds: int = 300 + idle_health_check_seconds: int = 30 + reconnect_attempts: int = 1 + + @classmethod + def from_environment(cls) -> ImapBatchPolicy: + return cls( + reuse_connections=os.environ.get("GOVOPLAN_IMAP_BATCH_REUSE", "true").strip().lower() + not in {"0", "false", "no", "off"}, + max_messages_per_connection=_batch_env_int( + "GOVOPLAN_IMAP_BATCH_MAX_MESSAGES", 100, minimum=1, maximum=10000, + ), + max_connection_age_seconds=_batch_env_int( + "GOVOPLAN_IMAP_BATCH_MAX_AGE_SECONDS", 300, minimum=1, maximum=3600, + ), + idle_health_check_seconds=_batch_env_int( + "GOVOPLAN_IMAP_BATCH_IDLE_HEALTH_CHECK_SECONDS", 30, minimum=0, maximum=3600, + ), + reconnect_attempts=_batch_env_int( + "GOVOPLAN_IMAP_BATCH_RECONNECT_ATTEMPTS", 1, minimum=0, maximum=5, + ), + ) + + +class ImapBatchSession: + """A bounded, sequential transport session, never an APPEND retry queue. + + Callers must authorize every message before invoking append. A failed APPEND + is never replayed here, even when the next independent message reconnects. + Folder discovery and its original wire names belong to this connection only. + """ + + def __init__(self, imap_config: ImapConfig, *, policy: ImapBatchPolicy | None = None): + self._host, self._port = _require_imap_config(imap_config) + self._config = imap_config.model_copy(deep=True) + self.policy = policy or ImapBatchPolicy.from_environment() + self._client: imaplib.IMAP4 | None = None + self._mock_connected = False + self._closed = False + self._in_use = Lock() + self._connection_count = 0 + self._connection_attempt_count = 0 + self._messages_on_connection = 0 + self._opened_at = 0.0 + self._last_used_at = 0.0 + self._folders: dict[str | None, tuple[str, str | bytes]] = {} + + @property + def connection_count(self) -> int: + return self._connection_count + + @property + def reconnect_count(self) -> int: + return max(0, self._connection_attempt_count - 1) + + def matches_config(self, config: ImapConfig) -> bool: + return config == self._config + + def __enter__(self) -> ImapBatchSession: + if self._closed: + raise ImapConfigurationError("The IMAP batch session is closed") + return self + + def __exit__(self, *_args: Any) -> None: + self.close() + + def _disconnect(self) -> None: + client, self._client = self._client, None + self._mock_connected = False + self._folders.clear() + self._messages_on_connection = 0 + if client is not None: + try: + client.logout() + except Exception as exc: + _log_imap_cleanup_failure("closing append batch", exc) + try: + # IMAP close() closes the selected mailbox, not the socket. + client.shutdown() + except Exception as cleanup_exc: + _log_imap_cleanup_failure("shutting down append batch", cleanup_exc) + + def close(self) -> None: + self._closed = True + self._disconnect() + + @contextmanager + def _exclusive_append(self) -> Iterator[None]: + if not self._in_use.acquire(blocking=False): + raise ImapConfigurationError("An IMAP batch only supports sequential APPENDs") + try: + if self._closed: + raise ImapConfigurationError("The IMAP batch session is closed") + yield + finally: + self._in_use.release() + + def _prepare_connection(self) -> None: + now = time.monotonic() + if self._client is not None or self._mock_connected: + if ( + not self.policy.reuse_connections + or self._messages_on_connection >= self.policy.max_messages_per_connection + or now - self._opened_at >= self.policy.max_connection_age_seconds + ): + self._disconnect() + elif self._client is not None and now - self._last_used_at >= self.policy.idle_health_check_seconds: + try: + typ, _data = self._client.noop() + if typ != "OK": + self._disconnect() + except (OSError, imaplib.IMAP4.error): + self._disconnect() + if self._client is not None or self._mock_connected: + return + for attempt in range(self.policy.reconnect_attempts + 1): + self._connection_attempt_count += 1 + try: + if is_mock_imap_host(self._config.host): + self._mock_connected = True + else: + self._client = _open_imap(self._config) + self._connection_count += 1 + self._opened_at = self._last_used_at = time.monotonic() + return + except (OSError, imaplib.IMAP4.abort): + # Connecting/authenticating has not issued APPEND. Never retry + # an authentication rejection or any error from APPEND itself. + if attempt >= self.policy.reconnect_attempts: + raise + + def append(self, message_bytes: bytes, *, folder: str | None = None) -> ImapAppendResult: + with self._exclusive_append(): + return self._append(message_bytes, folder=folder) + + def _append(self, message_bytes: bytes, *, folder: str | None) -> ImapAppendResult: + append_started = False + try: + self._prepare_connection() + reused = self._messages_on_connection > 0 + if self._mock_connected: + if consume_fail_next_imap(): + raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False) + target_folder = folder or ( + self._config.sent_folder if self._config.sent_folder != "auto" else "Sent" + ) or "Sent" + record = record_imap_append(message_bytes, folder=target_folder, imap_host=self._config.host) + response = f"mock append stored as {record.id}" + else: + client = self._client + assert client is not None + if folder not in self._folders: + target_folder = _effective_sent_folder( + config=self._config, requested_folder=folder, client=client, + ) + self._folders[folder] = (target_folder, _quote_mailbox_name(target_folder, client=client)) + target_folder, mailbox_argument = self._folders[folder] + internal_date = imaplib.Time2Internaldate(time.time()) + append_started = True + typ, data = client.append(mailbox_argument, "\\Seen", internal_date, message_bytes) + if typ != "OK": + raise ImapAppendError( + f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False, + ) + response = "; ".join(_decode_item(item) for item in (data or [])) or None + self._messages_on_connection += 1 + self._last_used_at = time.monotonic() + return ImapAppendResult( + host=self._host, + port=self._port, + security=self._config.security.value, + folder=target_folder, + bytes_appended=len(message_bytes), + response=response, + connection_sequence=self.connection_count, + session_reused=reused, + reconnect_count=self.reconnect_count, + ) + except (ImapAppendError, ImapConfigurationError): + self._disconnect() + raise + except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc: + self._disconnect() + raise ImapAppendError( + f"IMAP append failed: {exc}", temporary=not append_started, outcome_unknown=append_started, + ) from exc + except imaplib.IMAP4.error as exc: + self._disconnect() + raise ImapAppendError( + f"IMAP append failed: {exc}", temporary=False, outcome_unknown=append_started, + ) from exc + except Exception: + self._disconnect() + raise + + def append_message_to_sent( message_bytes: bytes, *, imap_config: ImapConfig, folder: str | None = None, + batch_session: ImapBatchSession | None = None, ) -> ImapAppendResult: - """Append a sent MIME message to the configured IMAP Sent folder. + """APPEND one MIME message; SMTP remains authoritative and independent. - The SMTP send remains authoritative. APPEND is a separate best-effort step - and should not be used to decide whether an email was sent. + An explicitly scoped batch may reuse its authenticated connection. Neither + mode retries an APPEND after transmission has started. """ - - host, port = _require_imap_config(imap_config) - if is_mock_imap_host(imap_config.host): - if consume_fail_next_imap(): - raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False) - target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent") - record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host) - return ImapAppendResult( - host=host, - port=port, - security=imap_config.security.value, - folder=target_folder, - bytes_appended=len(message_bytes), - response=f"mock append stored as {record.id}", - ) - - client: imaplib.IMAP4 | None = None - append_started = False - try: - client = _open_imap(imap_config) - target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client) - internal_date = imaplib.Time2Internaldate(time.time()) - append_started = True - typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes) - if typ != "OK": - raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False) - response = "; ".join(_decode_item(item) for item in (data or [])) or None - return ImapAppendResult( - host=host, - port=port, - security=imap_config.security.value, - folder=target_folder, - bytes_appended=len(message_bytes), - response=response, - ) - except ImapAppendError: - raise - except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc: - raise ImapAppendError( - f"IMAP append failed: {exc}", - temporary=not append_started, - outcome_unknown=append_started, - ) from exc - except imaplib.IMAP4.error as exc: - raise ImapAppendError( - f"IMAP append failed: {exc}", - temporary=False, - outcome_unknown=append_started, - ) from exc - finally: - if client is not None: - try: - client.logout() - except Exception as cleanup_exc: - _log_imap_cleanup_failure("appending sent message", cleanup_exc) + if batch_session is not None: + if not batch_session.matches_config(imap_config): + raise ImapConfigurationError("The IMAP batch configuration does not match this message") + return batch_session.append(message_bytes, folder=folder) + with ImapBatchSession( + imap_config, policy=ImapBatchPolicy(reuse_connections=False, reconnect_attempts=0), + ) as single: + return single.append(message_bytes, folder=folder) diff --git a/tests/test_campaign_imap_batch.py b/tests/test_campaign_imap_batch.py new file mode 100644 index 0000000..019cf4b --- /dev/null +++ b/tests/test_campaign_imap_batch.py @@ -0,0 +1,233 @@ +"""Batch transport reuse must never become an authorization or evidence cache.""" +from __future__ import annotations + +import imaplib +from dataclasses import asdict +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from govoplan_core.security.credential_envelopes import CredentialEnvelope +from govoplan_mail.backend import capabilities, server_hierarchy +from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerEndpoint +from govoplan_mail.backend.mail_profiles import MailProfileError +from govoplan_mail.backend.sending import imap as transport +from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError + +from test_campaign_protocol_authorization import hierarchy, imap_args # noqa: F401 + + +def client(): + return SimpleNamespace( + utf8_enabled=False, + append=Mock(return_value=("OK", [b"provider internal secret response"])), + logout=Mock(return_value=("BYE", [])), + noop=Mock(return_value=("OK", [])), + ) + + +def recovery(): + return SimpleNamespace(replayed=False, succeed_imap=Mock(), reject=Mock(), unknown=Mock()) + + +def test_reuses_transport_but_decrypts_and_authorizes_each_message_and_records_each_effect(hierarchy): + connection = client() + effects = [recovery(), recovery()] + with ( + patch.object(transport, "_open_imap", return_value=connection) as open_connection, + patch.object(capabilities, "begin_provider_effect_recovery", side_effect=effects) as begin, + patch.object(capabilities, "_authorized_campaign_profile", wraps=capabilities._authorized_campaign_profile) as authorize, + patch.object(capabilities, "assert_mail_policy_allows_send", wraps=capabilities.assert_mail_policy_allows_send) as policy, + patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt, + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1") as batch, + ): + assert batch.connection_count == 0 + open_connection.assert_not_called() + results = [capabilities.append_campaign_message_to_sent( + hierarchy.session, **imap_args(message_bytes=f"message-{index}".encode(), recovery_effect_id=f"effect-{index}"), + ) for index in range(2)] + assert batch.connection_count == 1 + assert batch.reconnect_count == 0 + assert authorize.call_count == policy.call_count == 2 + assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["imap-credential"] * 2 + assert [call.kwargs["effect_id"] for call in begin.call_args_list] == ["effect-0", "effect-1"] + assert [item.session_reused for item in results] == [False, True] + assert [item.connection_sequence for item in results] == [1, 1] + assert "secret" not in repr([asdict(item) for item in results]) + assert "imap.example" not in repr(results) + open_connection.assert_called_once() + connection.logout.assert_called_once() + for effect in effects: + effect.succeed_imap.assert_called_once_with(folder="Sent") + effect.reject.assert_not_called() + effect.unknown.assert_not_called() + + +@pytest.mark.parametrize("change", ["credential_revoked", "smtp_revision_changed", "imap_revision_changed"]) +def test_next_message_rechecks_current_authority_and_frozen_revisions_before_decryption(hierarchy, change): + connection = client() + with ( + patch.object(transport, "_open_imap", return_value=connection), + patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt, + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"), + ): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args()) + if change == "credential_revoked": + hierarchy.session.get(CredentialEnvelope, "imap-credential").is_active = False + else: + protocol = change.split("_")[0] + hierarchy.session.get(MailServerEndpoint, f"{protocol}-server").transport_revision = "changed" + hierarchy.session.commit() + with pytest.raises(MailProfileError): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(message_bytes=b"blocked")) + assert decrypt.call_count == 1 + connection.append.assert_called_once() + + +def test_effective_credential_policy_is_not_cached_by_warm_batch(hierarchy): + for scope in ("system", "tenant"): + hierarchy.session.get(MailProfilePolicy, f"{scope}-policy").policy = { + "smtp_credentials": {"inherit": False}, "imap_credentials": {"inherit": True}, + } + hierarchy.session.commit() + connection = client() + with ( + patch.object(transport, "_open_imap", return_value=connection), + patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt, + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"), + ): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(imap_credential_id=None)) + hierarchy.session.get(MailProfilePolicy, "tenant-policy").policy = { + "imap_credentials": {"inherit": False}, + } + hierarchy.session.commit() + with pytest.raises(MailProfileError, match="explicit credential"): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(imap_credential_id=None)) + assert decrypt.call_count == 1 + connection.append.assert_called_once() + + +def test_copying_context_to_a_worker_cannot_share_the_batch_connection(hierarchy): + from concurrent.futures import ThreadPoolExecutor + from contextvars import copy_context + with ( + patch.object(capabilities, "_authorized_campaign_profile") as authorize, + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"), + ThreadPoolExecutor(max_workers=1) as executor, + ): + future = executor.submit(copy_context().run, capabilities.append_campaign_message_to_sent, hierarchy.session, **imap_args()) + with pytest.raises(ImapConfigurationError, match="scope"): + future.result(timeout=5) + authorize.assert_not_called() + + +@pytest.mark.parametrize("scope", [{"tenant_id": "other"}, {"campaign_id": "other"}]) +def test_cross_scope_batch_is_rejected_before_authorization_decryption_evidence_or_network(hierarchy, scope): + with ( + patch.object(capabilities, "_authorized_campaign_profile") as authorize, + patch.object(capabilities, "begin_provider_effect_recovery") as begin, + patch.object(transport, "_open_imap") as open_connection, + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"), + pytest.raises(ImapConfigurationError, match="scope"), + ): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(**scope)) + authorize.assert_not_called() + begin.assert_not_called() + open_connection.assert_not_called() + + +def test_changed_resolved_secret_does_not_reuse_previous_authenticated_connection(hierarchy): + from govoplan_core.security.secrets import encrypt_secret + connections = [client(), client()] + with ( + patch.object(transport, "_open_imap", side_effect=connections) as open_connection, + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1") as batch, + ): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args()) + hierarchy.session.get(CredentialEnvelope, "imap-credential").secret_data_encrypted = encrypt_secret('{"password":"new fake password"}') + hierarchy.session.commit() + result = capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(message_bytes=b"new credential")) + assert batch.connection_count == result.connection_sequence == 2 + assert not result.session_reused + assert open_connection.call_count == 2 + assert open_connection.call_args_list[0].args[0].password != open_connection.call_args_list[1].args[0].password + for connection in connections: + connection.logout.assert_called_once() + connection.append.assert_called_once() + + +def test_nested_context_restores_outer_connection_and_always_cleans_up(hierarchy): + outer_client, inner_client = client(), client() + with patch.object(transport, "_open_imap", side_effect=[outer_client, inner_client]) as open_connection: + with capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1") as outer: + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args()) + with pytest.raises(ValueError, match="caller"): + with capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args()) + raise ValueError("caller failure") + inner_client.logout.assert_called_once() + assert capabilities._ACTIVE_IMAP_BATCH.get() is outer + result = capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args()) + assert result.session_reused + assert capabilities._ACTIVE_IMAP_BATCH.get() is None + assert open_connection.call_count == 2 + assert outer_client.append.call_count == 2 + outer_client.logout.assert_called_once() + + +def test_known_success_evidence_does_not_replay_even_with_warm_connection(hierarchy): + connection = client() + first, replayed = recovery(), recovery() + replayed.replayed = True + with ( + patch.object(transport, "_open_imap", return_value=connection), + patch.object(capabilities, "begin_provider_effect_recovery", side_effect=[first, replayed]), + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"), + ): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="same-effect")) + with pytest.raises(ImapAppendError, match="already succeeded") as caught: + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="same-effect")) + assert caught.value.outcome_unknown + connection.append.assert_called_once() + replayed.succeed_imap.assert_not_called() + + +def test_ambiguous_effect_is_recorded_once_sanitized_and_never_replayed(hierarchy): + connection = client() + connection.append.side_effect = imaplib.IMAP4.abort("provider host and secret") + effect = recovery() + with ( + patch.object(transport, "_open_imap", return_value=connection) as open_connection, + patch.object(capabilities, "begin_provider_effect_recovery", return_value=effect), + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"), + pytest.raises(ImapAppendError) as caught, + ): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="effect-1")) + assert caught.value.outcome_unknown + assert "secret" not in str(caught.value) + assert "secret" not in repr(effect.unknown.call_args) + effect.unknown.assert_called_once() + effect.succeed_imap.assert_not_called() + effect.reject.assert_not_called() + open_connection.assert_called_once() + connection.append.assert_called_once() + connection.logout.assert_called_once() + + +def test_evidence_finalization_failure_closes_batch_and_prevents_further_effects(hierarchy): + connection = client() + effect = recovery() + effect.succeed_imap.side_effect = OSError("evidence store unavailable") + with ( + patch.object(transport, "_open_imap", return_value=connection), + patch.object(capabilities, "begin_provider_effect_recovery", return_value=effect), + capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"), + ): + with pytest.raises(ImapAppendError) as caught: + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="effect-1")) + assert caught.value.outcome_unknown + with pytest.raises(ImapConfigurationError): + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(message_bytes=b"must not run")) + connection.append.assert_called_once() + connection.logout.assert_called_once() diff --git a/tests/test_campaign_protocol_authorization.py b/tests/test_campaign_protocol_authorization.py new file mode 100644 index 0000000..d025095 --- /dev/null +++ b/tests/test_campaign_protocol_authorization.py @@ -0,0 +1,187 @@ +"""Real stored hierarchy and policy; provider calls are replaced, never live.""" +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest +from sqlalchemy import Column, String, Table, create_engine +from sqlalchemy.orm import Session + +from govoplan_core.admin.models import SystemSettings +from govoplan_core.core.campaigns import CampaignMailPolicyContext +from govoplan_core.db.base import Base +from govoplan_core.security.credential_envelopes import CredentialEnvelope +from govoplan_core.security.secrets import encrypt_secret +from govoplan_core.tenancy.scope import Tenant +from govoplan_mail.backend import capabilities, mail_profiles, server_hierarchy +from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerCredentialBinding, MailServerEndpoint, MailServerProfile +from govoplan_mail.backend.sending import smtp as smtp_module + + +@pytest.fixture +def hierarchy(tmp_path): + engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'smtp-policy.db'}") + if "access_users" not in Base.metadata.tables: + Table("access_users", Base.metadata, Column("id", String(36), primary_key=True)) + for table in (Base.metadata.tables["access_users"], SystemSettings.__table__, Tenant.__table__, + CredentialEnvelope.__table__, MailServerProfile.__table__, MailServerEndpoint.__table__, + MailServerCredentialBinding.__table__, MailProfilePolicy.__table__): + table.create(engine) + with Session(engine) as session: + session.add(SystemSettings(id="global", settings={})) + session.add(Tenant(id="tenant-1", slug="test", name="Test", settings={})) + profile = MailServerProfile(id="profile-1", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Test mail", slug="test", + smtp_config={"host": "smtp.example.test", "port": 587, "security": "starttls"}, + imap_config={"host": "imap.example.test", "port": 993, "security": "tls"}, + smtp_transport_revision="smtp-current", imap_transport_revision="imap-current", inherit_to_lower_scopes=True) + session.add(profile) + for protocol, port in (("smtp", 587), ("imap", 993)): + server = MailServerEndpoint(id=f"{protocol}-server", profile_id=profile.id, tenant_id="tenant-1", protocol=protocol, + name=protocol, scope_type="tenant", scope_id="tenant-1", inherit_to_lower_scopes=True, + is_default=True, is_active=True, transport_revision=f"{protocol}-current", + config={"host": f"{protocol}.example.test", "port": port, "security": "starttls" if protocol == "smtp" else "tls"}) + credential = CredentialEnvelope(id=f"{protocol}-credential", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", + name=protocol, credential_kind="username_password", public_data={"username": f"{protocol}-user"}, + secret_data_encrypted=encrypt_secret(json.dumps({"password": f"fake-{protocol}-password"})), secret_keys=["password"], + allowed_modules=["mail"], allowed_server_refs=[f"mail:{protocol}-server"], inherit_to_lower_scopes=True, is_active=True) + session.add_all([server, credential, MailServerCredentialBinding(id=f"{protocol}-binding", server_id=server.id, + credential_id=credential.id, is_default=True)]) + for scope in ("system", "tenant"): + session.add(MailProfilePolicy(id=f"{scope}-policy", tenant_id=None if scope == "system" else "tenant-1", + scope_type=scope, scope_id=None if scope == "system" else "tenant-1", + policy={"smtp_credentials": {"inherit": False}, "imap_credentials": {"inherit": False}})) + session.commit() + context = CampaignMailPolicyContext(id="campaign-1", tenant_id="tenant-1") + provider = SimpleNamespace(get_campaign_mail_policy_context=lambda *_args, **_kwargs: context) + # Only the optional Campaign context provider and external I/O are mocked. + # Profiles, policy inheritance, endpoint/credential ACLs and decryption are real. + with patch.object(mail_profiles, "_campaign_policy_provider", return_value=provider), \ + patch("socket.create_connection", side_effect=AssertionError("No live network in regression tests")): + yield SimpleNamespace(session=session, profile=profile, context=context) + engine.dispose() + + +def smtp_args(**overrides): + return {"tenant_id": "tenant-1", "campaign_id": "campaign-1", "profile_id": "profile-1", + "envelope_from": "sender@example.test", "envelope_recipients": ["recipient@example.test"], "from_header": "sender@example.test", + "expected_smtp_transport_revision": "smtp-current", "smtp_server_id": "smtp-server", "smtp_credential_id": "smtp-credential", **overrides} + + +def selection(): + return {"smtp_server_id": "smtp-server", "smtp_credential_id": "smtp-credential", "imap_server_id": "imap-server", "imap_credential_id": "imap-credential"} + + +def test_smtp_batch_accepts_explicit_smtp_when_other_protocol_requires_explicit_selection(hierarchy): + # The full frozen selection is valid; runtime SMTP deliberately carries only SMTP. + summary = capabilities.campaign_profile_delivery_summary(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", profile_id="profile-1", **selection()) + assert summary["smtp_available"] and summary["imap_available"] + fake_connection = Mock() + with patch.object(smtp_module, "_open_smtp", return_value=fake_connection) as opener, \ + patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt: + with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args()) as batch: + assert batch.status == "ready" + assert batch.connection_count == 1 + assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["smtp-credential"] + assert opener.call_args.args[0].username == "smtp-user" + fake_connection.sendmail.assert_not_called() + fake_connection.send_message.assert_not_called() + + +def test_smtp_single_uses_same_selected_protocol_authorization(hierarchy): + result = SimpleNamespace(envelope_recipients=["recipient@example.test"], refused_recipients={}) + with patch.object(capabilities, "send_email_bytes", return_value=result) as send, \ + patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt: + sent = capabilities.send_campaign_email_bytes(hierarchy.session, message_bytes=b"frozen test message", **smtp_args()) + assert sent.accepted_count == 1 + assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["smtp-credential"] + assert send.call_args.kwargs["smtp_config"].username == "smtp-user" + + +def imap_args(**overrides): + return {"tenant_id": "tenant-1", "campaign_id": "campaign-1", "profile_id": "profile-1", + "message_bytes": b"frozen test message", "folder": "Sent", + "expected_smtp_transport_revision": "smtp-current", "expected_imap_transport_revision": "imap-current", + # An IMAP-only call need not submit an unrelated SMTP credential. + "smtp_server_id": "smtp-server", "imap_server_id": "imap-server", "imap_credential_id": "imap-credential", **overrides} + + +def test_imap_append_checks_and_decrypts_only_selected_protocol(hierarchy): + with patch.object(capabilities, "append_message_to_sent", return_value=SimpleNamespace(folder="Sent")) as append, \ + patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt: + result = capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args()) + assert result.folder == "Sent" + assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["imap-credential"] + assert append.call_args.kwargs["imap_config"].username == "imap-user" + + +@pytest.mark.parametrize("operation", ["smtp_batch", "smtp_single", "imap_append", "imap_endpoint_only"]) +def test_selected_protocol_still_requires_explicit_credentials_before_decryption_or_provider(hierarchy, operation): + if operation == "imap_endpoint_only": + hierarchy.profile.imap_config = None # Current endpoint exists without the legacy mirror. + hierarchy.session.commit() + with patch.object(server_hierarchy, "resolve_credential_envelope", side_effect=AssertionError("Policy must reject before decryption")) as decrypt, \ + patch.object(smtp_module, "_open_smtp", side_effect=AssertionError("No network")) as opener, \ + patch.object(capabilities, "send_email_bytes", side_effect=AssertionError("No SMTP")) as send, \ + patch.object(capabilities, "append_message_to_sent", side_effect=AssertionError("No IMAP")) as append: + with pytest.raises(mail_profiles.MailProfileError, match=f"effective {'SMTP' if operation.startswith('smtp') else 'IMAP'}"): + if operation == "smtp_batch": + with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args(smtp_credential_id=None)): pass + elif operation == "smtp_single": + capabilities.send_campaign_email_bytes(hierarchy.session, message_bytes=b"test", **smtp_args(smtp_credential_id=None)) + else: + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(imap_credential_id=None)) + decrypt.assert_not_called(); opener.assert_not_called(); send.assert_not_called(); append.assert_not_called() + + +@pytest.mark.parametrize("operation", ["smtp_batch", "smtp_single", "imap_append"]) +@pytest.mark.parametrize("mutation", ["stale_revision", "inactive_credential", "wrong_server", "wrong_tenant"]) +def test_selected_transport_revision_and_credential_authority_remain_fail_closed(hierarchy, operation, mutation): + protocol = "smtp" if operation.startswith("smtp") else "imap" + overrides = {} + if mutation == "stale_revision": + overrides[f"expected_{protocol}_transport_revision"] = "stale-build-revision" + elif mutation == "wrong_server": + overrides[f"{protocol}_credential_id"] = "imap-credential" if protocol == "smtp" else "smtp-credential" + else: + credential = hierarchy.session.get(CredentialEnvelope, f"{protocol}-credential") + if mutation == "inactive_credential": credential.is_active = False + else: credential.tenant_id = "other-tenant" + hierarchy.session.commit() + with patch.object(server_hierarchy, "resolve_credential_envelope", side_effect=AssertionError("Reject stale/unauthorized before decrypt")) as decrypt, \ + patch.object(smtp_module, "_open_smtp", side_effect=AssertionError("No network")) as opener, \ + patch.object(capabilities, "send_email_bytes", side_effect=AssertionError("No SMTP")) as send, \ + patch.object(capabilities, "append_message_to_sent", side_effect=AssertionError("No IMAP")) as append: + with pytest.raises(mail_profiles.MailProfileError): + if operation == "smtp_batch": + with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args(**overrides)): pass + elif operation == "smtp_single": + capabilities.send_campaign_email_bytes(hierarchy.session, message_bytes=b"test", **smtp_args(**overrides)) + else: + capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(**overrides)) + decrypt.assert_not_called(); opener.assert_not_called(); send.assert_not_called(); append.assert_not_called() + + +@pytest.mark.parametrize("missing", ["smtp_credential_id", "imap_credential_id"]) +def test_full_authoring_and_summary_still_check_both_protocols_without_decryption(hierarchy, missing): + complete = selection() + raw = {"server": {"mail_profile_id": "profile-1", **complete}} + mail_profiles.assert_campaign_mail_policy_allows_json(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", raw_json=raw) + incomplete = {**complete, missing: None} + with patch.object(server_hierarchy, "resolve_credential_envelope", side_effect=AssertionError("Summary must not decrypt")) as decrypt: + with pytest.raises(mail_profiles.MailProfileError, match="explicit credential selection"): + capabilities.campaign_profile_delivery_summary(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", profile_id="profile-1", **incomplete) + with pytest.raises(mail_profiles.MailProfileError, match="explicit credential selection"): + mail_profiles.assert_campaign_mail_policy_allows_json(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", raw_json={"server": {"mail_profile_id": "profile-1", **incomplete}}) + decrypt.assert_not_called() + + +def test_batch_still_enforces_all_recipient_domains_before_connection(hierarchy): + row = hierarchy.session.get(MailProfilePolicy, "system-policy") + row.policy = {**row.policy, "blacklist": {"recipient_domains": ["blocked.example"]}} + hierarchy.session.commit() + with patch.object(smtp_module, "_open_smtp", side_effect=AssertionError("Forbidden recipient must never connect")) as opener: + with pytest.raises(mail_profiles.MailProfileError, match="effective Mail policy"): + with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args(envelope_recipients=["ok@example.test", "no@blocked.example"])): pass + opener.assert_not_called() diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 23b8c3d..0a78c64 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from govoplan_core.core.modules import DocumentationContext from govoplan_mail.backend.documentation import ( + _credential_line, documentation_configuration_states, documentation_topics, ) @@ -26,6 +27,46 @@ class _Principal: class MailRuntimeDocumentationTests(unittest.TestCase): + def test_mailbox_toolbar_contract_documents_context_refresh_and_read_only_bounds_in_both_languages(self) -> None: + from govoplan_mail.backend.manifest import manifest + topic = next(item for item in manifest.documentation if item.id == "mail.workflow.read-mailbox") + self.assertEqual(set(topic.documentation_types), {"user", "admin"}) + self.assertTrue({"mail.mailbox.reload", "mail.mailbox.tools"}.issubset(topic.metadata["help_contexts"])) + for phrase in ("one right-aligned Reload", "Mailbox tools", "IMAP retains the current page", "JMAP starts a fresh cursor chain", "Failed refreshes preserve usable loaded data", "ignore late reads", "do not grant profile administration rights"): + self.assertIn(phrase, topic.body) + for phrase in ("genau einmal Neuladen rechts", "Postfachwerkzeuge", "IMAP behält die Seite", "JMAP beginnt", "Verspätete Antworten", "Fehlgeschlagene Aktualisierungen", "keine Profilverwaltungsrechte"): + self.assertIn(phrase, topic.translations["de"]["body"]) + + def test_imap_batch_contract_documents_bounds_and_per_message_safety_in_both_languages(self) -> None: + from govoplan_mail.backend.manifest import manifest + topic = next(item for item in manifest.documentation if item.id == "mail.reference.campaign-delivery-contract") + for body in (topic.body, topic.translations["de"]["body"]): + self.assertIn("campaign_imap_batch", body) + self.assertIn("GOVOPLAN_IMAP_BATCH_REUSE", body) + self.assertIn("MULTIAPPEND", body) + self.assertIn("100", body) + self.assertIn("300", body) + self.assertIn("permissions and recovery evidence are never cached", topic.body) + self.assertIn("no automatic APPEND replay", topic.body) + self.assertIn("Berechtigungen und Nachweise werden nicht zwischengespeichert", topic.translations["de"]["body"]) + + def test_campaign_contract_documents_protocol_scoped_runtime_and_complete_validation(self) -> None: + from govoplan_mail.backend.manifest import manifest + topic = next(item for item in manifest.documentation if item.id == "mail.reference.campaign-delivery-contract") + self.assertIn("Runtime credential-selection checks are protocol-scoped", topic.body) + self.assertIn("still check both configured protocols", topic.body) + self.assertIn("before credential decryption or provider contact", topic.body) + self.assertIn("protokollbezogen", topic.translations["de"]["body"]) + self.assertIn("weiterhin beide konfigurierten Protokolle", topic.translations["de"]["body"]) + + def test_credential_policy_guidance_describes_explicit_mail_references_not_local_secrets(self) -> None: + text = _credential_line({"smtp_credentials": {"inherit": False}, "imap_credentials": {"inherit": True}}) + self.assertIn("SMTP requires an explicit Mail credential", text) + self.assertIn("IMAP allows a profile default or explicit Mail credential", text) + self.assertIn("Secrets remain in Mail", text) + self.assertNotIn("local credentials", text) + self.assertNotIn("only for protocols that inherit", text) + def setUp(self) -> None: self.session = Session() @@ -182,7 +223,7 @@ class MailRuntimeDocumentationTests(unittest.TestCase): topics = {topic.id: topic for topic in get_manifest().documentation} self.assertEqual(topics["mail.workflow.choose-and-test-profile"].metadata["help_contexts"], ["mail.profiles", "app.settings"]) - self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list", "mail.mailbox"]) + self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list", "mail.mailbox", "mail.mailbox.reload", "mail.mailbox.tools"]) self.assertIn("mail.admin.profiles", topics["mail.profiles-and-policy"].metadata["help_contexts"]) self.assertIn("mail.bounce-processing", topics["mail.bounce-processing"].metadata["help_contexts"]) diff --git a/tests/test_imap_batch.py b/tests/test_imap_batch.py new file mode 100644 index 0000000..17e63b8 --- /dev/null +++ b/tests/test_imap_batch.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import imaplib +import unittest +from dataclasses import replace +from unittest.mock import Mock, patch + +from govoplan_mail.backend.config import ImapConfig +from govoplan_mail.backend.sending.imap import ( + ImapAppendError, + ImapBatchPolicy, + ImapBatchSession, + ImapConfigurationError, + append_message_to_sent, +) + + +class BatchClient: + utf8_enabled = False + + def __init__(self, *, wire_folder=b"Gesendete &APw-bermittlung"): + self.login = Mock(return_value=("OK", [])) + self.list = Mock(return_value=("OK", [b'(\\Sent) "/" "' + wire_folder + b'"'])) + self.noop = Mock(return_value=("OK", [])) + self.append = Mock(return_value=("OK", [b"APPEND complete"])) + self.logout = Mock(return_value=("BYE", [])) + self.shutdown = Mock() + + +def config(**changes): + return ImapConfig( + host="imap.example.test", port=993, security="tls", + username="service", password="secret", sent_folder="auto", + ).model_copy(update=changes) + + +class ImapBatchTests(unittest.TestCase): + def test_one_login_discovery_and_logout_for_many_sequential_appends(self): + client = BatchClient() + with ( + patch("govoplan_mail.backend.sending.imap.validate_outbound_host"), + patch("govoplan_mail.backend.sending.imap._OutboundPolicyIMAP4SSL", return_value=client) as connect, + ImapBatchSession(config()) as batch, + ): + results = [append_message_to_sent(bytes([i]), imap_config=config(), batch_session=batch) for i in range(50)] + self.assertEqual(batch.connection_count, 1) + self.assertEqual(batch.reconnect_count, 0) + client.logout.assert_not_called() + connect.assert_called_once() + client.login.assert_called_once_with("service", "secret") + client.list.assert_called_once() + client.noop.assert_not_called() + client.logout.assert_called_once() + self.assertEqual(client.append.call_count, 50) + self.assertEqual([call.args[3] for call in client.append.call_args_list], [bytes([i]) for i in range(50)]) + self.assertEqual({call.args[0] for call in client.append.call_args_list}, {'"Gesendete &APw-bermittlung"'}) + self.assertEqual({item.folder for item in results}, {"Gesendete übermittlung"}) + self.assertEqual([item.session_reused for item in results], [False] + [True] * 49) + self.assertEqual({item.connection_sequence for item in results}, {1}) + + def test_count_rotation_rediscovers_original_wire_names_on_new_connection(self): + first = BatchClient(wire_folder=b"&U,BTFw-&ZeVnLIqe-") + second = BatchClient(wire_folder=b"&U,BTF2XlZyyKng-") + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]) as connect, + ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), max_messages_per_connection=2)) as batch, + ): + results = [batch.append(b"message") for _ in range(3)] + self.assertEqual(connect.call_count, 2) + first.list.assert_called_once() + second.list.assert_called_once() + first.logout.assert_called_once() + second.logout.assert_called_once() + self.assertEqual([item.connection_sequence for item in results], [1, 1, 2]) + self.assertEqual([item.session_reused for item in results], [False, True, False]) + self.assertEqual(results[-1].reconnect_count, 1) + self.assertEqual(first.append.call_args.args[0], '"&U,BTFw-&ZeVnLIqe-"') + self.assertEqual(second.append.call_args.args[0], '"&U,BTF2XlZyyKng-"') + + def test_age_rotation_is_checked_before_next_append(self): + clock = [0.0] + first, second = BatchClient(), BatchClient() + with ( + patch("govoplan_mail.backend.sending.imap.time.monotonic", side_effect=lambda: clock[0]), + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]), + ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), max_connection_age_seconds=20)) as batch, + ): + batch.append(b"one") + clock[0] = 20.0 + self.assertFalse(batch.append(b"two").session_reused) + first.noop.assert_not_called() + self.assertEqual(first.append.call_count + second.append.call_count, 2) + + def test_idle_probe_can_reconnect_before_append_without_replaying_prior_message(self): + clock = [0.0] + first, second = BatchClient(), BatchClient() + first.noop.side_effect = imaplib.IMAP4.abort("gone") + with ( + patch("govoplan_mail.backend.sending.imap.time.monotonic", side_effect=lambda: clock[0]), + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]), + ImapBatchSession(config()) as batch, + ): + batch.append(b"one") + clock[0] = 31.0 + result = batch.append(b"two") + first.noop.assert_called_once() + self.assertEqual(first.append.call_args.args[3], b"one") + self.assertEqual(second.append.call_args.args[3], b"two") + self.assertEqual(result.reconnect_count, 1) + + def test_healthy_idle_probe_reuses_connection(self): + client = BatchClient() + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client), + ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), idle_health_check_seconds=0)) as batch, + ): + batch.append(b"one") + self.assertTrue(batch.append(b"two").session_reused) + client.noop.assert_called_once() + + def test_only_pre_effect_connection_failures_are_retried(self): + client = BatchClient() + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[OSError("offline"), client]) as connect, + ImapBatchSession(config()) as batch, + ): + result = batch.append(b"one") + self.assertEqual(connect.call_count, 2) + client.append.assert_called_once() + self.assertEqual(result.reconnect_count, 1) + + def test_pre_effect_reconnects_are_bounded_and_not_unknown(self): + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=OSError("offline")) as connect, + ImapBatchSession(config()) as batch, + self.assertRaises(ImapAppendError) as caught, + ): + batch.append(b"one") + self.assertEqual(connect.call_count, 2) + self.assertTrue(caught.exception.temporary) + self.assertFalse(caught.exception.outcome_unknown) + + def test_authentication_rejection_is_not_retried(self): + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=imaplib.IMAP4.error("bad login")) as connect, + ImapBatchSession(config()) as batch, + self.assertRaises(ImapAppendError) as caught, + ): + batch.append(b"one") + connect.assert_called_once() + self.assertFalse(caught.exception.temporary) + self.assertFalse(caught.exception.outcome_unknown) + + def test_ambiguous_append_is_never_replayed_and_connection_is_discarded(self): + first, second = BatchClient(), BatchClient() + first.append.side_effect = imaplib.IMAP4.abort("accepted but reply lost") + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]) as connect, + ImapBatchSession(config()) as batch, + ): + with self.assertRaises(ImapAppendError) as caught: + batch.append(b"uncertain") + self.assertTrue(caught.exception.outcome_unknown) + self.assertFalse(caught.exception.temporary) + self.assertEqual(connect.call_count, 1) + first.logout.assert_called_once() + batch.append(b"different independently claimed message") + first.append.assert_called_once() + second.append.assert_called_once() + self.assertNotEqual(first.append.call_args.args[3], second.append.call_args.args[3]) + + def test_definitive_append_rejection_is_not_retried_or_unknown(self): + client = BatchClient() + client.append.return_value = ("NO", [b"quota"]) + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client) as connect, + ImapBatchSession(config()) as batch, + self.assertRaises(ImapAppendError) as caught, + ): + batch.append(b"one") + self.assertFalse(caught.exception.outcome_unknown) + connect.assert_called_once() + client.append.assert_called_once() + + def test_logout_failure_does_not_reverse_accepted_message(self): + client = BatchClient() + client.logout.side_effect = OSError("gone") + with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client): + result = append_message_to_sent(b"one", imap_config=config()) + self.assertEqual(result.bytes_appended, 3) + client.shutdown.assert_called_once() + + def test_disabling_reuse_keeps_individual_appends_and_closes_every_connection(self): + clients = [BatchClient() for _ in range(3)] + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=clients), + ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), reuse_connections=False)) as batch, + ): + results = [batch.append(b"one") for _ in clients] + self.assertEqual([item.connection_sequence for item in results], [1, 2, 3]) + self.assertFalse(any(item.session_reused for item in results)) + for client in clients: + client.append.assert_called_once() + client.logout.assert_called_once() + + def test_config_mismatch_closed_and_overlapping_calls_fail_before_provider(self): + with patch("govoplan_mail.backend.sending.imap._open_imap") as connect: + batch = ImapBatchSession(config()) + with self.assertRaises(ImapConfigurationError): + append_message_to_sent(b"one", imap_config=config(password="different"), batch_session=batch) + with batch._exclusive_append(), self.assertRaises(ImapConfigurationError): + batch.append(b"overlap") + batch.close() + with self.assertRaises(ImapConfigurationError): + batch.append(b"closed") + connect.assert_not_called() + + def test_environment_values_are_bounded(self): + with patch.dict("os.environ", { + "GOVOPLAN_IMAP_BATCH_REUSE": "false", + "GOVOPLAN_IMAP_BATCH_MAX_MESSAGES": "0", + "GOVOPLAN_IMAP_BATCH_MAX_AGE_SECONDS": "invalid", + "GOVOPLAN_IMAP_BATCH_IDLE_HEALTH_CHECK_SECONDS": "999999", + "GOVOPLAN_IMAP_BATCH_RECONNECT_ATTEMPTS": "999999", + }): + policy = ImapBatchPolicy.from_environment() + self.assertFalse(policy.reuse_connections) + self.assertEqual(policy.max_messages_per_connection, 1) + self.assertEqual(policy.max_connection_age_seconds, 300) + self.assertEqual(policy.idle_health_check_seconds, 3600) + self.assertEqual(policy.reconnect_attempts, 5) diff --git a/tests/test_imap_mailbox_encoding.py b/tests/test_imap_mailbox_encoding.py new file mode 100644 index 0000000..8265dfb --- /dev/null +++ b/tests/test_imap_mailbox_encoding.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from govoplan_mail.backend.config import ImapConfig +from govoplan_mail.backend.sending.imap import ( + ImapAppendError, + ImapConfigurationError, + _decode_mailbox_name, + _encode_mailbox_name, + _extract_mailbox_name, + _list_imap_folders_on_client, + _quote_mailbox_name, + _select_readonly, + append_message_to_sent, +) + + +class MailboxClient: + utf8_enabled = False + capabilities = ("IMAP4REV1", "UTF8=ACCEPT") + untagged_responses = {"EXISTS": [b"2"], "UIDVALIDITY": [b"1"]} + + def __init__(self, listing=None): + self.listing = listing or [] + self.list_calls = 0 + self.status_calls = [] + self.select_calls = [] + self.append_calls = [] + self.logged_out = False + + def list(self): + self.list_calls += 1 + return "OK", self.listing + + def status(self, mailbox, items): + self.status_calls.append((mailbox, items)) + return "OK", [b'"mailbox" (MESSAGES 2 UNSEEN 1)'] + + def select(self, mailbox, readonly=False): + self.select_calls.append((mailbox, readonly)) + return "OK", [b"2"] + + def response(self, code): + return "OK", [b"1"] if code == "UIDVALIDITY" else [] + + def append(self, mailbox, flags, date_time, message): + self.append_calls.append((mailbox, flags, message)) + return "OK", [b"APPEND complete"] + + def logout(self): + self.logged_out = True + return "BYE", [] + + +class ImapMailboxEncodingTests(unittest.TestCase): + def test_rfc_and_real_provider_vectors_round_trip(self): + # RFC 3501 section 5.1.3 plus the reported German folder and UTF-16 + # surrogate pairs. A plain '+' is not a shift in modified UTF-7. + for display, wire in ( + ("INBOX", "INBOX"), + ("Entwürfe", "Entw&APw-rfe"), + ("R&D", "R&-D"), + ("+Plus & Sons", "+Plus &- Sons"), + ("~peter/mail/台北/日本語", "~peter/mail/&U,BTFw-/&ZeVnLIqe-"), + ("📨", "&2D3c6A-"), + ('A \\ "B"', 'A \\ "B"'), + ): + with self.subTest(display=display): + self.assertEqual(_encode_mailbox_name(display), wire) + self.assertEqual(_decode_mailbox_name(wire), display) + + def test_list_decodes_names_before_standard_folder_detection_and_status(self): + client = MailboxClient([ + b'(\\HasNoChildren) "/" "Entw&APw-rfe"', + b'(\\HasNoChildren) "/" "Gel&APY-scht"', + b'(\\HasNoChildren) "/" "R&-D"', + ]) + result = _list_imap_folders_on_client( + client, host="imap.example.org", port=993, security="tls", include_status=True, + ) + self.assertEqual([folder.name for folder in result.folders], ["Entwürfe", "Gelöscht", "R&D"]) + self.assertEqual(result.detected_folder_mappings, {"drafts": "Entwürfe", "trash": "Gelöscht"}) + self.assertTrue(all(folder.message_count == 2 and folder.unseen_count == 1 for folder in result.folders)) + self.assertEqual([call[0] for call in client.status_calls], [ + '"Entw&APw-rfe"', '"Gel&APY-scht"', '"R&-D"', + ]) + self.assertEqual(client.list_calls, 1) + + def test_literal_names_are_decoded_without_stripping_or_unquoting_content(self): + for wire, expected in ( + (b"Entw&APw-rfe", "Entwürfe"), + (b'"R&-D" ', '"R&D" '), + ): + with self.subTest(wire=wire): + line = b'(\\Drafts) "/" {' + str(len(wire)).encode("ascii") + b"}" + self.assertEqual(_extract_mailbox_name((line, wire)), (expected, {"\\drafts"})) + self.assertIsNone(_extract_mailbox_name(b"")) + self.assertIsNone(_extract_mailbox_name(None)) + + def test_list_rejects_wrong_literal_lengths_and_invalid_provider_encoding(self): + with self.assertRaisesRegex(ImapAppendError, "literal"): + _extract_mailbox_name((b'() "/" {99}', b"INBOX")) + for wire in (b"&APw", b"&!bad-", b"&AGE-", b"&AA-", b"&APx-", b"&2AA-", b"Entw\xffrfe"): + with self.subTest(wire=wire), self.assertRaisesRegex(ImapAppendError, "encoding"): + _extract_mailbox_name(b'() "/" "' + wire + b'"') + + def test_select_encodes_unicode_and_quotes_protocol_metacharacters(self): + client = MailboxClient() + folder = 'Entwürfe / R&D / "Q" \\' + self.assertEqual(_select_readonly(client, folder), (2, "1")) + self.assertEqual(client.select_calls, [('"Entw&APw-rfe / R&-D / \\"Q\\" \\\\"', True)]) + self.assertEqual(client.list_calls, 0) + + def test_quoted_name_escaping_round_trips_independently_of_charset_encoding(self): + for folder in ('Entwürfe "R&D"', ' \\"quoted"\\ ', 'R&D', '📨/日本語', 'back\\slash'): + with self.subTest(folder=folder): + quoted = _quote_mailbox_name(folder) + self.assertEqual(_extract_mailbox_name('() "/" ' + quoted), (folder, set())) + + def test_saved_wire_names_resolve_without_double_encoding(self): + client = MailboxClient([b'() "/" "Entw&APw-rfe"']) + _select_readonly(client, "Entw&APw-rfe") + _select_readonly(client, "Entwürfe") + self.assertEqual(client.select_calls, [('"Entw&APw-rfe"', True)] * 2) + self.assertEqual(client.list_calls, 1) + + def test_literal_name_wins_when_legacy_alias_is_ambiguous(self): + client = MailboxClient([ + b'() "/" "Entw&APw-rfe"', + b'() "/" "Entw&-APw-rfe"', + ]) + _select_readonly(client, "Entw&APw-rfe") + self.assertEqual(client.select_calls, [('"Entw&-APw-rfe"', True)]) + + def test_saved_ampersand_and_literal_ampersand_remain_distinct(self): + client = MailboxClient([b'() "/" "R&-D"']) + _select_readonly(client, "R&-D") + _select_readonly(client, "R&D") + self.assertEqual(client.select_calls, [('"R&-D"', True)] * 2) + + def test_provider_wire_form_is_preserved_on_the_listed_connection(self): + client = MailboxClient([b'() "/" "&U,BTFw-&ZeVnLIqe-"']) + result = _list_imap_folders_on_client( + client, host="imap.example.org", port=993, security="tls", include_status=True, + ) + self.assertEqual(result.folders[0].name, "台北日本語") + self.assertEqual(client.status_calls[0][0], '"&U,BTFw-&ZeVnLIqe-"') + + def test_utf8_mode_preserves_literal_ampersands_and_does_not_decode_again(self): + client = MailboxClient(['() "/" "Entwürfe &APw-"'.encode("utf-8")]) + client.utf8_enabled = True + result = _list_imap_folders_on_client( + client, host="imap.example.org", port=993, security="tls", include_status=True, + ) + self.assertEqual(result.folders[0].name, "Entwürfe &APw-") + _select_readonly(client, result.folders[0].name) + self.assertEqual(client.select_calls, [('"Entwürfe &APw-"', True)]) + self.assertEqual(client.status_calls[0][0], '"Entwürfe &APw-"') + + def test_utf8_capability_alone_does_not_change_the_encoding(self): + client = MailboxClient() + self.assertEqual(_quote_mailbox_name("Entwürfe", client=client), '"Entw&APw-rfe"') + with self.assertRaisesRegex(ImapAppendError, "encoding"): + _extract_mailbox_name('() "/" "Entwürfe"'.encode("utf-8")) + + def test_changing_utf8_mode_invalidates_cached_wire_names(self): + client = MailboxClient([b'() "/" "Entw&APw-rfe"']) + _list_imap_folders_on_client( + client, host="imap.example.org", port=993, security="tls", include_status=False, + ) + client.utf8_enabled = True + self.assertEqual(_quote_mailbox_name("Entwürfe", client=client), '"Entwürfe"') + + def test_select_rejects_control_characters_before_sending_any_command(self): + for folder in ("INBOX\r\nLOGOUT", "bad\x00folder", "bad\x7ffolder", "bad\u2028folder"): + client = MailboxClient() + with self.subTest(folder=folder), self.assertRaisesRegex(ImapConfigurationError, "control"): + _select_readonly(client, folder) + self.assertEqual(client.select_calls, []) + + def test_append_auto_detects_unicode_sent_folder_and_uses_original_wire_name(self): + client = MailboxClient([b'(\\Sent) "/" "Gesendet &APw-"']) + config = ImapConfig(host="imap.example.org", sent_folder="auto") + with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client): + result = append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config) + self.assertEqual(result.folder, "Gesendet ü") + self.assertEqual(client.append_calls[0][0], '"Gesendet &APw-"') + self.assertTrue(client.logged_out) + + def test_append_explicit_unicode_and_saved_wire_names_target_the_same_mailbox(self): + for folder in ("Entwürfe", "Entw&APw-rfe"): + with self.subTest(folder=folder): + client = MailboxClient([b'() "/" "Entw&APw-rfe"']) + config = ImapConfig(host="imap.example.org", sent_folder=folder) + with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client): + append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config) + self.assertEqual(client.append_calls[0][0], '"Entw&APw-rfe"') + + def test_failed_legacy_lookup_never_selects_or_appends_to_a_guessed_mailbox(self): + client = MailboxClient() + with patch.object(client, "list", return_value=("NO", [b"Denied"])), self.assertRaisesRegex( + ImapAppendError, "resolving a saved folder name", + ): + _select_readonly(client, "Entw&APw-rfe") + self.assertEqual(client.select_calls, []) + + def test_append_saved_wire_name_failure_is_not_an_unknown_append_outcome(self): + client = MailboxClient() + config = ImapConfig(host="imap.example.org", sent_folder="Entw&APw-rfe") + with ( + patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client), + patch.object(client, "list", side_effect=OSError("connection lost")), + self.assertRaises(ImapAppendError) as caught, + ): + append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config) + self.assertTrue(caught.exception.temporary) + self.assertFalse(caught.exception.outcome_unknown) + self.assertEqual(client.append_calls, []) + self.assertTrue(client.logged_out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mail_profile_helpers.py b/tests/test_mail_profile_helpers.py index 99791b2..2c20389 100644 --- a/tests/test_mail_profile_helpers.py +++ b/tests/test_mail_profile_helpers.py @@ -563,7 +563,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase): "credential-1", ) - def test_campaign_delivery_fails_when_policy_requires_local_credentials(self): + def test_campaign_delivery_fails_when_policy_requires_missing_explicit_credentials(self): profile = SimpleNamespace(imap_config=None) policy = EffectiveMailProfilePolicy( smtp_credentials=EffectiveCredentialPolicy(inherit=False), @@ -572,6 +572,44 @@ class MailProfilePolicyHelperTests(unittest.TestCase): with self.assertRaisesRegex(MailProfileError, "explicit credential selection"): _assert_campaign_inherits_profile_credentials(profile, policy) + def test_explicit_mail_credentials_satisfy_independent_protocol_selection_policy(self): + profile = SimpleNamespace(imap_config={"host": "imap.example.test"}) + policy = EffectiveMailProfilePolicy( + smtp_credentials=EffectiveCredentialPolicy(inherit=False), + imap_credentials=EffectiveCredentialPolicy(inherit=False), + ) + with self.assertRaisesRegex(MailProfileError, "effective IMAP"): + _assert_campaign_inherits_profile_credentials(profile, policy, {"smtp_credential_id": "smtp-credential"}) + _assert_campaign_inherits_profile_credentials(profile, policy, { + "smtp_credential_id": "smtp-credential", "imap_credential_id": "imap-credential", + }) + # Allowing a default never forbids an explicitly selected Mail credential. + _assert_campaign_inherits_profile_credentials(profile, EffectiveMailProfilePolicy(), { + "smtp_credential_id": "smtp-credential", "imap_credential_id": "imap-credential", + }) + + def test_credential_selection_false_is_overridable_only_without_an_ancestor_lock(self): + for locked in (False, True): + with self.subTest(locked=locked): + policy = EffectiveMailProfilePolicy() + _merge_policy(policy, { + "smtp_credentials": {"inherit": False}, + "allow_lower_level_limits": {"smtp_credentials.inherit": not locked}, + }, source="system") + _merge_policy(policy, { + "smtp_credentials": {"inherit": True}, + "allow_lower_level_limits": {"smtp_credentials.inherit": True}, + }, source="tenant", source_id="tenant-1") + self.assertEqual(policy.smtp_credentials.inherit, not locked) + self.assertEqual(policy.allow_lower_level_limits["smtp_credentials.inherit"], not locked) + + def test_null_credential_selection_inherits_the_parent_choice(self): + policy = EffectiveMailProfilePolicy() + _merge_policy(policy, {"smtp_credentials": {"inherit": False}}, source="system") + _merge_policy(policy, {"smtp_credentials": {"inherit": None}}, source="tenant", source_id="tenant-1") + self.assertFalse(policy.smtp_credentials.inherit) + self.assertEqual(policy.smtp_credentials.inherit_source, "system") + def test_merge_policy_respects_locked_lower_level_limits(self): policy = EffectiveMailProfilePolicy() _merge_policy( diff --git a/webui/package-lock.json b/webui/package-lock.json index 8232558..743090c 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,17 +1,17 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.26", + "version": "0.1.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/mail-webui", - "version": "0.1.26", + "version": "0.1.27", "devDependencies": { "typescript": "^5.7.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/package.json b/webui/package.json index 918b8c0..73b1944 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.26", + "version": "0.1.27", "private": true, "type": "module", "main": "src/index.ts", @@ -14,7 +14,7 @@ "./styles/mail-profiles.css": "./src/styles/mail-profiles.css" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs index 071dc4c..a54b719 100644 --- a/webui/scripts/test-interface-pattern-language.mjs +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -6,8 +6,9 @@ function read(relativePath) { return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); } -const profiles = read("../src/features/mail/MailProfileManagement.tsx"); +const profiles = `${read("../src/features/mail/MailProfileManagement.tsx")}\n${read("../src/features/mail/MailProfilePolicyEditor.tsx")}`; const mailbox = read("../src/features/mail/MailboxPage.tsx"); +const mailApi = read("../src/api/mail.ts"); const bounces = read("../src/features/mail/MailBouncePage.tsx"); const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx"); const moduleSource = read("../src/module.ts"); @@ -34,6 +35,32 @@ 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/); +const openFolderNode = mailbox.slice(mailbox.indexOf(" function openFolderNode("), mailbox.indexOf(" function toggleFolderNode(")); +assert.doesNotMatch(openFolderNode, /toggleFolderNode\(/, "Folder labels select; only the folder button expands or collapses."); +assert.match(openFolderNode, /setSelectedFolderGroup\(\{ id: node\.id, label: node\.label \}\)/); +assert.match(openFolderNode, /messageDetailRequestRef\.current \+= 1/, "Selecting a grouping invalidates pending message detail."); +assert.match(mailbox, /selectedFolderGroup\?\.id \?\? findFolderNodeId/); +assert.match(mailbox, /]*height="viewport"/); +assert.match(mailbox, / void reloadMailbox\(\)/); +assert.ok(mailbox.indexOf(" \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/); assert.match(bounces, /DocumentationHelpLink/); @@ -75,7 +102,8 @@ assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}\n${mod assert.match(moduleSource, /"mail\.profiles"/); assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/); assert.match(styles, /@media \(max-width: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/); -assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-toolbar\.file-manager-toolbar[\s\S]*grid-template-columns: 1fr/); +assert.doesNotMatch(styles, /\.mailbox-toolbar/, "Core owns workspace toolbar wrapping and action ordering."); +assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-message-row[\s\S]*grid-template-columns: 1fr/); for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) { assert.match(migration, new RegExp(archetype.replace("/", "\\/"))); diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 9d3b6b7..3e08c44 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -272,11 +272,14 @@ export async function createMailAddressContact( }); } -export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise { - return apiGetList(settings, "/api/v1/mail/profiles", "profiles", { +type MailboxReadOptions = Pick; + +export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string, options?: MailboxReadOptions): Promise { + const response = await apiFetch<{ profiles?: MailServerProfile[] | null }>(settings, apiPath("/api/v1/mail/profiles", { include_inactive: includeInactive ? true : undefined, campaign_id: campaignId - }); + }), options); + return response.profiles ?? []; } export async function fetchMailSettingsDelta( @@ -679,12 +682,12 @@ export async function listMailProfileImapFolders( ); } -export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false, protocol: MailMailboxProtocol = "imap"): Promise { +export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false, protocol: MailMailboxProtocol = "imap", options?: MailboxReadOptions): Promise { return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, { include_status: includeStatus ? true : undefined, refresh: refresh ? true : undefined, protocol - })); + }), options); } export async function bootstrapMailbox( @@ -694,7 +697,8 @@ export async function bootstrapMailbox( limit = 50, offset = 0, refresh = false, - protocol: MailMailboxProtocol = "imap" + protocol: MailMailboxProtocol = "imap", + options?: MailboxReadOptions ): Promise { return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, { folder, @@ -702,7 +706,7 @@ export async function bootstrapMailbox( offset, refresh: refresh ? true : undefined, protocol - })); + }), options); } export async function listMailboxMessages( @@ -714,7 +718,8 @@ export async function listMailboxMessages( cursor?: string | null, refresh = false, protocol: MailMailboxProtocol = "imap", - query?: string | null + query?: string | null, + options?: MailboxReadOptions ): Promise { return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, { folder, @@ -724,7 +729,7 @@ export async function listMailboxMessages( refresh: refresh ? true : undefined, protocol, q: query || undefined - })); + }), options); } export async function getMailboxMessage( @@ -732,9 +737,10 @@ export async function getMailboxMessage( profileId: string, folder: string, uid: string, - protocol: MailMailboxProtocol = "imap" + protocol: MailMailboxProtocol = "imap", + options?: MailboxReadOptions ): Promise { - return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder, protocol })); + return apiFetch(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder, protocol }), options); } export async function getMailboxChanges( diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 15e7c26..4b8bd21 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -1,46 +1,8 @@ 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, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailJmapTransportSettings, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui"; +import { FormGrid, ActionBlockerHint, ConnectionTree, DocumentationHelpLink, LoadingFrame, MailServerSettingsPanel, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailJmapTransportSettings, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings } 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 { - bindMailServerCredential, - createMailServerCredential, - createMailServerEndpoint, - createMailServerProfile, - deactivateMailServerEndpoint, - deactivateMailServerProfile, - fetchMailSettingsDelta, - getMailProfilePolicy, - listImapFolders, - listMailProfileImapFolders, - mailProfilePatternKeys, - mailProfilePolicyLimitKeys, - listAvailableMailCredentials, - updateMailProfilePolicy, - testImapSettings, - testMailProfileImap, - testMailProfileJmap, - testMailProfileSmtp, - testSmtpSettings, - unlinkMailServerCredential, - updateMailServerCredential, - updateMailServerEndpoint, - updateMailServerProfile, - type MailCredentialEnvelope, - type MailCredentialPolicy, - type MailImapTestPayload, - type MailProfilePatternKey, - type MailProfilePatternRules, - type MailProfilePolicy, - type MailProfilePolicyLimitKey, - type MailProfileScope, - type MailSecurity, - type MailServerEndpoint, - type MailServerProfile, - type MailServerProfilePayload, - type MailServerProfileUpdatePayload, - type MailSmtpTestPayload } from -"../../api/mail"; +import { bindMailServerCredential, createMailServerCredential, createMailServerEndpoint, createMailServerProfile, deactivateMailServerEndpoint, deactivateMailServerProfile, fetchMailSettingsDelta, listImapFolders, listMailProfileImapFolders, listAvailableMailCredentials, testImapSettings, testMailProfileImap, testMailProfileJmap, testMailProfileSmtp, testSmtpSettings, unlinkMailServerCredential, updateMailServerCredential, updateMailServerEndpoint, updateMailServerProfile, type MailCredentialEnvelope, type MailImapTestPayload, type MailProfilePolicy, type MailProfileScope, type MailSecurity, type MailServerEndpoint, type MailServerProfile, type MailServerProfilePayload, type MailServerProfileUpdatePayload, type MailSmtpTestPayload } from "../../api/mail"; import { validateMailPolicy } from "./mailPolicyValidation"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; @@ -48,29 +10,10 @@ import { ConfirmDialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { FormField, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; -import { - ReferenceMultiSelect, - customReferenceOption, - platformModuleReferenceProvider, - staticReferenceOptionProvider, - usePlatformLanguage, - usePlatformModules, - type ReferenceOption -} from "@govoplan/core-webui"; -import { - mailProfileEditTargetInitialSection, - mailProfileEditTargetPanelMode, - mailProfileEditTargetShowsProfileFields, - mailProfileEditTargetShowsSettingsPanel, - mailProfileEditTargetVisibleSections, - mailProfileCreateStageCanContinue, - mailProfileCreateStagePanel, - mailProfileCreateStages, - mailProfileCreateCredentialsPayload, - type MailProfileCreateStage, - type MailProfileEditTarget, - type MailProfileProtocol -} from "./mailProfileEditorModel"; +import { ReferenceMultiSelect, customReferenceOption, platformModuleReferenceProvider, staticReferenceOptionProvider, usePlatformLanguage, usePlatformModules, type ReferenceOption } from "@govoplan/core-webui"; +import { mailProfileEditTargetInitialSection, mailProfileEditTargetPanelMode, mailProfileEditTargetShowsProfileFields, mailProfileEditTargetShowsSettingsPanel, mailProfileEditTargetVisibleSections, mailProfileCreateStageCanContinue, mailProfileCreateStagePanel, mailProfileCreateStages, mailProfileCreateCredentialsPayload, type MailProfileCreateStage, type MailProfileEditTarget, type MailProfileProtocol } from "./mailProfileEditorModel"; +import { MailProfilePolicyEditor, errorMessage, MAIL_PROFILE_DOCUMENTATION, scopeLabel, transportLabel, scopeOrder } from "./MailProfilePolicyEditor"; +export { MailProfilePolicyEditor } from "./MailProfilePolicyEditor"; export type MailProfileTargetOption = { id: string; label: string; @@ -131,23 +74,8 @@ type MailProfileScopeManagerProps = { canWritePolicy: boolean; }; -type MailProfilePolicyEditorProps = { - settings: ApiSettings; - scopeType: MailProfileScope; - scopeId?: string | null; - campaignId?: string | null; - profiles: MailServerProfile[]; - ownerUserId?: string | null; - ownerGroupId?: string | null; - canWrite: boolean; - locked?: boolean; - title?: string; - description?: string; - onSaved?: () => void | Promise; -}; type EditingProfile = MailServerProfile | "new" | null; -type PolicyFlagValue = "inherit" | "allow" | "deny"; type MailProfileTreeRow = {kind: "profile";id: string;profile: MailServerProfile;} | {kind: "server";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;server: MailServerEndpoint;} | @@ -160,36 +88,13 @@ type PendingHierarchyRemoval = const securityOptions = mailServerSecurityOptions as readonly MailSecurity[]; -const MAIL_PROFILE_DOCUMENTATION = { - topicId: "mail.profiles-and-policy", - documentationType: "admin" -} as const; const MAIL_PROFILE_WORKFLOW_DOCUMENTATION = { topicId: "mail.workflow.choose-and-test-profile", documentationType: "user" } as const; -const patternLabels: Record = { - smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8", - imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78", - jmap_hosts: "JMAP hostnames", - envelope_senders: "i18n:govoplan-mail.envelope_senders.269065cd", - from_headers: "i18n:govoplan-mail.from_headers.b3ea473b", - recipient_domains: "i18n:govoplan-mail.recipient_domains.cb9b7b44" -}; -const blankPolicy: MailProfilePolicy = { - allowed_profile_ids: [], - allow_user_profiles: null, - allow_group_profiles: null, - allow_campaign_profiles: null, - smtp_credentials: {}, - imap_credentials: {}, - whitelist: {}, - blacklist: {}, - allow_lower_level_limits: {} -}; export function MailProfileScopeManager({ settings, @@ -856,296 +761,6 @@ export function MailProfileScopeManager({ } -export function MailProfilePolicyEditor({ - settings, - scopeType, - scopeId = null, - campaignId = null, - profiles, - ownerUserId = null, - ownerGroupId = null, - canWrite, - locked = false, - title = "i18n:govoplan-mail.mail_profile_policy.f2ac4b92", - description = "i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4", - onSaved -}: MailProfilePolicyEditorProps) { - const [policy, setPolicy] = useState(blankPolicy); - const [effectivePolicy, setEffectivePolicy] = useState(null); - const [parentPolicy, setParentPolicy] = useState(null); - const [effectivePolicySources, setEffectivePolicySources] = useState([]); - const [savedPolicyKey, setSavedPolicyKey] = useState(policyDraftKey(blankPolicy)); - const [loading, setLoading] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [success, setSuccess] = useState(""); - const [profileEffectivePolicy, setProfileEffectivePolicy] = useState(null); - - const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign"; - const scopeReady = !requiresTarget || Boolean(scopeId); - const policyDirty = scopeReady && policyDraftKey(policy) !== savedPolicyKey; - - useUnsavedDraftGuard({ - dirty: policyDirty, - onSave: savePolicy, - onDiscard: () => setPolicy(JSON.parse(savedPolicyKey) as MailProfilePolicy) - }); - - useEffect(() => {void loadPolicy();}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]); - - async function loadPolicy() { - setError(""); - setSuccess(""); - if (!scopeReady) { - setPolicy(blankPolicy); - setSavedPolicyKey(policyDraftKey(blankPolicy)); - setEffectivePolicy(null); - setParentPolicy(null); - setEffectivePolicySources([]); - return; - } - setLoading(true); - try { - const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId); - const loadedPolicy = normalizePolicy(response.policy); - setPolicy(loadedPolicy); - setSavedPolicyKey(policyDraftKey(loadedPolicy)); - setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null); - setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null); - setEffectivePolicySources(response.effective_policy_sources ?? []); - } catch (err) { - setPolicy(blankPolicy); - setSavedPolicyKey(policyDraftKey(blankPolicy)); - setEffectivePolicy(null); - setParentPolicy(null); - setEffectivePolicySources([]); - setError(errorMessage(err)); - } finally { - setLoading(false); - } - } - - async function savePolicy(): Promise { - if (!scopeReady) return false; - setBusy(true); - setError(""); - setSuccess(""); - try { - const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicyForSave(policy, parentPolicy, scopeType), scopeId); - const savedPolicy = normalizePolicy(response.policy); - setPolicy(savedPolicy); - setSavedPolicyKey(policyDraftKey(savedPolicy)); - setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null); - setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null); - setEffectivePolicySources(response.effective_policy_sources ?? []); - setSuccess("i18n:govoplan-mail.mail_profile_policy_saved.666847bf"); - await onSaved?.(); - return true; - } catch (err) { - setError(errorMessage(err)); - return false; - } finally { - setBusy(false); - } - } - - const candidateProfiles = useMemo( - () => profileCandidatesForPolicy(profiles, scopeType, scopeId, ownerUserId, ownerGroupId), - [ownerGroupId, ownerUserId, profiles, scopeId, scopeType] - ); - const isSystem = scopeType === "system"; - const displayPolicy = useMemo(() => isSystem ? concreteSystemPolicy(policy) : policy, [isSystem, policy]); - const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []); - const disabled = locked || busy || loading || !canWrite || !scopeReady; - const policySaveBlocker = mailPolicyDisabledReason(locked, canWrite, scopeReady, loading, busy, policyDirty); - const parentAllowedProfileIds = parentPolicy?.allowed_profile_ids?.length ? new Set(parentPolicy.allowed_profile_ids) : null; - const parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false; - const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false; - const parentBlocksCampaignProfiles = parentPolicy?.allow_campaign_profiles === false; - const showAllowColumn = scopeType !== "campaign"; - const showEffectiveColumn = !isSystem; - const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids"); - const blockedProfileDefinitions = [ - parentBlocksUserProfiles ? "user" : "", - parentBlocksGroupProfiles ? "group" : "", - parentBlocksCampaignProfiles ? "i18n:govoplan-mail.campaign_local_settings.920ecb62" : ""]. - filter(Boolean).join(", "); - const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType); - - function patchPolicy(patch: Partial) { - setPolicy((current) => normalizePolicy({ ...current, ...patch })); - } - - function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) { - patchPolicy({ [key]: flagToBoolean(value) }); - } - - function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) { - const nextRules = { ...(policy[kind] ?? {}) }; - const parsed = parsePatternList(text); - if (parsed.length > 0) nextRules[key] = parsed;else - delete nextRules[key]; - patchPolicy({ [kind]: nextRules }); - } - - function parentAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean { - return !parentPolicy || parentPolicy.allow_lower_level_limits?.[key] !== false; - } - - function localAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean { - const localValue = policy.allow_lower_level_limits?.[key]; - if (localValue !== undefined) return localValue && parentAllowsMailLimit(key); - return parentAllowsMailLimit(key); - } - - function setAllowLowerLevelLimit(key: MailProfilePolicyLimitKey, allowed: boolean) { - patchPolicy({ allow_lower_level_limits: { ...(policy.allow_lower_level_limits ?? {}), [key]: allowed } }); - } - - function lowerLevelLimitToggle(key: MailProfilePolicyLimitKey, label: ReactNode = "i18n:govoplan-mail.allow_override.ffa6e9a0"): ReactNode | undefined { - if (!showAllowColumn) return undefined; - const parentLocked = !parentAllowsMailLimit(key); - return ( - setAllowLowerLevelLimit(key, checked)} - label={label} />); - - - } - - return ( - - - - - - }> - - -
- {(locked || !canWrite || !scopeReady) && - - } - {description &&

{description}

} - {error && {error}} - {success && {success}} - -
- -

i18n:govoplan-mail.profile_allow_list.507dfe6c

-
- {lowerLevelLimitToggle("allowed_profile_ids")} - -
-
-

{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}

- ({ - id: profile.id, - label: profile.name, - description: `${scopeLabel(profile)} · ${transportLabel(profile.smtp)}`, - disabled: disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id)) - }))} - selected={[...selectedProfileIds]} - onChange={(allowedProfileIds) => patchPolicy({ allowed_profile_ids: [...allowedProfileIds].sort() })} - emptyText="i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85" - /> - {parentAllowedProfileIds &&

i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179 {parentAllowedProfileIds.size} i18n:govoplan-mail.profile_s.742e9200

} -
- -
-

i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d

- - setFlag("allow_user_profiles", value)} />} - effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_user_profiles, effectivePolicy) : undefined} - allowControl={showAllowColumn ?
{lowerLevelLimitToggle("allow_user_profiles")}
: undefined} - effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_user_profiles", effectivePolicyPath) : undefined} /> - - setFlag("allow_group_profiles", value)} />} - effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_group_profiles, effectivePolicy) : undefined} - allowControl={showAllowColumn ?
{lowerLevelLimitToggle("allow_group_profiles")}
: undefined} - effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_group_profiles", effectivePolicyPath) : undefined} /> - - setFlag("allow_campaign_profiles", value)} />} - effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_campaign_profiles, effectivePolicy) : undefined} - allowControl={showAllowColumn ?
{lowerLevelLimitToggle("allow_campaign_profiles")}
: undefined} - effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_campaign_profiles", effectivePolicyPath) : undefined} /> - -
- {blockedProfileDefinitions && i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a {blockedProfileDefinitions} i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d} -
- -
-

i18n:govoplan-mail.wildcard_rules.54fb3fc0

-
-
- i18n:govoplan-mail.policy_target.a19dcee9 - i18n:govoplan-mail.whitelist.53c2ad30 - i18n:govoplan-mail.blacklist.7b2dd04c - {showAllowColumn && i18n:govoplan-mail.lower_levels.940821ee} -
- {mailProfilePatternKeys.map((key) => -
-
- {patternLabels[key]} -
- setPattern("whitelist", key, text)} /> - setPattern("blacklist", key, text)} /> - {showAllowColumn && -
- {lowerLevelLimitToggle(i18nMessage("i18n:govoplan-mail.whitelist_value.d4cc3755", { value0: key }) as MailProfilePolicyLimitKey, "i18n:govoplan-mail.whitelist.53c2ad30")} - {lowerLevelLimitToggle(i18nMessage("i18n:govoplan-mail.blacklist_value.556334d0", { value0: key }) as MailProfilePolicyLimitKey, "i18n:govoplan-mail.blacklist.7b2dd04c")} -
- } -
- )} -
-
- - {showEffectiveColumn && effectivePolicy && -
-

i18n:govoplan-mail.policy_path.1ba91ee5

- -

i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d

-
- } -
-
-
); - -} function ProfileForm({ settings, @@ -1632,20 +1247,7 @@ function ProfileForm({ } -function PolicyFlagControl({ value, disabled, includeInherit = true, inheritOnly = false, allowDisabled = false, onChange }: {value: PolicyFlagValue;disabled: boolean;includeInherit?: boolean;inheritOnly?: boolean;allowDisabled?: boolean;onChange: (value: PolicyFlagValue) => void;}) { - const selectedValue = inheritOnly ? "inherit" : value; - return ( - ); -} - -function PatternTextareaControl({ value, disabled, onChange }: {value: string;disabled: boolean;onChange: (value: string) => void;}) { - return