Compare commits
11
Commits
v0.1.18
...
2ad122056e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad122056e | ||
|
|
9ac8847559 | ||
|
|
e0e00d7000 | ||
|
|
393331574a | ||
|
|
bb8c60abb9 | ||
|
|
c59c67bd18 | ||
|
|
761dd76b9e | ||
|
|
28f357e151 | ||
|
|
cff428b475 | ||
|
|
77f44dfab5 | ||
|
|
2c5585ba99 |
@@ -13,7 +13,7 @@ mailbox, policy, and delivery-evidence consequences described here.
|
||||
| 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 |
|
||||
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
||||
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `PageLayout`, `ContentGrid`, `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
||||
| `/mail/bounces` observation table | Inspect correlated or unmatched delivery-status evidence | Evidence/reporting | Medium because recipient and diagnostic data may be sensitive | Bounded sanitized rows, textual status, filters, correlation state, and no raw bounce body |
|
||||
| Bounce watcher removal | Stop future scans while retaining evidence | Destructive confirmation | Medium and reversible by recreating the watcher; observations are retained | Shared `ConfirmDialog` states the immediate consequence and retained evidence |
|
||||
| `mail.profiles` and credential-reference capabilities | Let another module select or validate Mail-owned transport without receiving secrets | Governed capability composition | High because the selected identity can perform external effects | Stable references and Core capability boundaries; no sibling-private WebUI import; authorization and credential resolution remain Mail-owned |
|
||||
@@ -22,6 +22,9 @@ mailbox, policy, and delivery-evidence consequences described here.
|
||||
|
||||
- Loading, success, error, empty results, permission blockers, and destructive
|
||||
confirmation use Core components. Mail does not reproduce the shell.
|
||||
- Bounce processing now delegates its page inset, sticky responsive heading,
|
||||
route actions, alert regions, loading boundary, scrolling, and help audience
|
||||
to Core `PageLayout`; Mail retains only watcher and evidence semantics.
|
||||
- A target-dependent profile surface cannot load until a concrete user, group,
|
||||
or campaign is selected. The blocker identifies the responsible actor and
|
||||
destination instead of silently hiding the editor.
|
||||
|
||||
+50
-3
@@ -43,6 +43,28 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the
|
||||
module registry. Optional consumers provide narrow context through capabilities;
|
||||
Mail does not import their ORM or service implementations.
|
||||
|
||||
## Deployment configuration packages
|
||||
|
||||
Mail registers the `mail.configuration` capability for `smtp_profile`
|
||||
fragments. The provider reads the validated `mail.smtp` entry from the
|
||||
installer-generated infrastructure capability receipt. Receipt host and port
|
||||
are authoritative; the generic package workflow asks for missing non-secret
|
||||
transport fields such as security mode. An external relay may operate without
|
||||
authentication, or the operator may select an existing credential-envelope id.
|
||||
Inline usernames, passwords, tokens, and secret values are rejected.
|
||||
|
||||
Tenant scope is the default. A system-scoped profile requires system settings
|
||||
or governance write authority. The fragment's stable slug is its idempotency
|
||||
identity: an absent profile is created, an exact profile is skipped, and a
|
||||
conflicting profile is preserved unless the reviewed fragment explicitly sets
|
||||
`on_conflict` to `update`. Credential bindings are added idempotently and are
|
||||
never removed merely because a package omits a credential reference.
|
||||
|
||||
Preflight does not prove SMTP reachability. After apply, use the normal Mail
|
||||
profile test and Ops health surfaces. If the receipt says SMTP is unavailable,
|
||||
is invalid, or is not mounted, import is blocked with an operator-facing
|
||||
resolution instead of creating a partial profile.
|
||||
|
||||
## Interface patterns and unavailable actions
|
||||
|
||||
Mail uses the platform's shared explorer, connection tree, adaptive form,
|
||||
@@ -71,6 +93,13 @@ configuration. It has a stable id, lifecycle state, scope, owner context, and
|
||||
non-secret connection metadata. Passwords are write-only encrypted values and
|
||||
are never returned through list/read/capability responses.
|
||||
|
||||
An IMAP server may map the standard Inbox, Sent, Drafts, Trash, Archive, and
|
||||
Junk roles to exact provider folder names. These mappings belong to the reusable
|
||||
profile/server. Empty roles retain automatic behavior. The historical
|
||||
`imap.sent_folder` value is read as the Sent mapping and remains synchronized
|
||||
for compatibility; a Campaign-specific Sent override still wins for that
|
||||
Campaign.
|
||||
|
||||
Profiles may be scoped to system, tenant, user, group, or campaign context.
|
||||
Scope controls where a profile can be discovered; effective policy can narrow
|
||||
that further. A visible profile is not automatically authorized for every
|
||||
@@ -156,7 +185,17 @@ ordinary consumers to bypass reusable profiles.
|
||||
The current mailbox UI and API are read-only. An authorized user can list IMAP
|
||||
folders, page through messages, and inspect a bounded full message. Folder
|
||||
names are parsed and quoted defensively; Sent-folder discovery uses provider
|
||||
flags and common names.
|
||||
flags and common names. The list exposes the provider's `Seen` flag as a
|
||||
read/unread indicator without changing it. It also labels whether the current
|
||||
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.
|
||||
|
||||
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.
|
||||
Attachments and provider/content failures remain explicit rather than being
|
||||
silently interpreted as an empty message.
|
||||
|
||||
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
|
||||
not mutate read/unread, delete, move, or reply state. Message responses are
|
||||
@@ -205,8 +244,10 @@ account's user scope. Grant `mail:profile:write_own` for self-service;
|
||||
`mail:profile:write` remains broad profile administration authority.
|
||||
|
||||
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
||||
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
||||
behavior, and timeouts. Sender/envelope/recipient constraints belong to
|
||||
2. Configure SMTP, optional IMAP, TLS mode, account identity, standard folder
|
||||
mappings, and timeouts. Folder discovery proposes provider-visible Inbox,
|
||||
Sent, Drafts, Trash, Archive, and Junk names without mutating the mailbox.
|
||||
Sender/envelope/recipient constraints belong to
|
||||
effective Mail policy; Campaign rate limits remain delivery configuration.
|
||||
3. Enter credentials only in the dedicated credential fields. Returned profile
|
||||
data indicates whether credentials are configured without returning them.
|
||||
@@ -456,6 +497,12 @@ Before claiming a Mail composition is production-ready:
|
||||
is stable.
|
||||
- POP3 except for a future explicit legacy download/import requirement.
|
||||
- A full mail client with compose/reply/move/delete/read-state mutation.
|
||||
- Quick Access may launch the operating environment's configured composer via
|
||||
`mailto:`. That explicit handoff is not a GovOPlaN Mail delivery: it selects
|
||||
no Mail profile or credential, bypasses no Mail policy, and reports no
|
||||
GovOPlaN delivery result. Recent-message and Drafts links remain read-only
|
||||
deep links into the authorized Mail profile and preserve their Quick Access
|
||||
return context.
|
||||
- Recovery-ledger adoption for future provider-side move, delete, and flag
|
||||
mutations; no such production path exists in the current read-only mailbox.
|
||||
- Proof that process-local throttling coordinates multiple workers when Redis
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from govoplan_core.mail.config import (
|
||||
ImapConfig,
|
||||
ImapFolderMappings,
|
||||
ImapServerConfig,
|
||||
SmtpConfig,
|
||||
SmtpServerConfig,
|
||||
@@ -13,6 +14,7 @@ from govoplan_core.mail.config import (
|
||||
|
||||
__all__ = [
|
||||
"ImapConfig",
|
||||
"ImapFolderMappings",
|
||||
"ImapServerConfig",
|
||||
"SmtpConfig",
|
||||
"SmtpServerConfig",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,7 @@ def slugify_profile_name(value: str) -> str:
|
||||
|
||||
|
||||
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]:
|
||||
payload = config.model_dump(mode="json")
|
||||
payload = config.model_dump(mode="json", exclude_none=True)
|
||||
username_was_supplied = "username" in config.model_fields_set
|
||||
password_was_supplied = "password" in config.model_fields_set
|
||||
username = payload.pop("username", None)
|
||||
|
||||
@@ -10,7 +10,9 @@ from govoplan_core.core.mail import (
|
||||
CAPABILITY_MAIL_BOUNCE_PROCESSING,
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX,
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||
CAPABILITY_MAIL_POSTBOX_BRIDGE,
|
||||
)
|
||||
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_DELIVERY
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
@@ -26,6 +28,8 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
QuickAccessTool,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
@@ -38,6 +42,7 @@ from govoplan_core.core.provider_governance import (
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_mail.backend.configuration_provider import MAIL_CONFIGURATION_CAPABILITY
|
||||
from govoplan_mail.backend.documentation import (
|
||||
documentation_configuration_states,
|
||||
documentation_topics,
|
||||
@@ -93,6 +98,13 @@ def _mail_retirement_provider(session: object | None, module_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _configuration_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_mail.backend.configuration_provider import SqlMailConfigurationProvider
|
||||
|
||||
return SqlMailConfigurationProvider()
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
@@ -301,13 +313,14 @@ manifest = ModuleManifest(
|
||||
name="Mail",
|
||||
version="0.1.18",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "addresses", "calendar", "search"),
|
||||
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
||||
ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="mail.bounce_processing", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_MAIL_POSTBOX_BRIDGE, version="1.0.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -334,6 +347,12 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_POSTBOX_DELIVERY,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
@@ -376,6 +395,33 @@ manifest = ModuleManifest(
|
||||
ViewSurface(id="mail.admin.group-servers", module_id="mail", kind="section", label="Group mail servers", order=20),
|
||||
ViewSurface(id="mail.admin.user-servers", module_id="mail", kind="section", label="User mail servers", order=20),
|
||||
ViewSurface(id="mail.settings.profiles", module_id="mail", kind="section", label="Personal mail profiles", order=10),
|
||||
ViewSurface(id="mail.quick_access.messages", module_id="mail", kind="quick_access", label="Mail Quick Access", order=80),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="communication",
|
||||
module_id="mail",
|
||||
label="i18n:govoplan-core.product_area.communication",
|
||||
icon="mail",
|
||||
description="i18n:govoplan-core.product_area.communication_description",
|
||||
surface_ids=("mail.nav.mail", "mail.route.mail"),
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="mail.messages",
|
||||
module_id="mail",
|
||||
category_id="messages",
|
||||
label="i18n:govoplan-mail.mail.92379cbb",
|
||||
description="i18n:govoplan-mail.quick_access_description",
|
||||
surface_id="mail.quick_access.messages",
|
||||
icon="mail",
|
||||
full_page_path="/mail",
|
||||
required_any=("mail:mailbox:read",),
|
||||
order=10,
|
||||
modes=("browse", "compose"),
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
@@ -403,6 +449,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
MAIL_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
|
||||
"govoplan_mail.backend.capabilities",
|
||||
@@ -416,8 +463,126 @@ manifest = ModuleManifest(
|
||||
"govoplan_mail.backend.bounce_processing",
|
||||
fromlist=["SqlMailBounceProcessingProvider"],
|
||||
).SqlMailBounceProcessingProvider(),
|
||||
CAPABILITY_MAIL_POSTBOX_BRIDGE: lambda context: __import__(
|
||||
"govoplan_mail.backend.postbox_bridge",
|
||||
fromlist=["create_postbox_bridge"],
|
||||
).create_postbox_bridge(context),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="mail.postbox.bridge",
|
||||
title="Bridge selected Mail observations into Postbox",
|
||||
summary="Deliver one immutable IMAP observation to an explicit function Postbox without turning Postbox into a mailbox account.",
|
||||
body=(
|
||||
"Mail exposes an optional, idempotent bridge capability. A configured caller supplies an exact Mail profile, folder, UIDVALIDITY, UID, raw observation, "
|
||||
"and typed Postbox target. Mail parses bounded headers, plaintext, participants, and attachment evidence; Postbox owns target resolution, "
|
||||
"delivery, retention, receipts, and access. The bridge stores no credentials in Postbox and never treats account login as Postbox membership."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_user", "mail_admin", "administrator"),
|
||||
related_modules=("postbox", "idm", "organizations"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Ausgewählte Mail-Beobachtungen an Postbox übergeben",
|
||||
"summary": "Eine unveränderliche IMAP-Beobachtung an ein ausdrücklich bestimmtes Funktionspostfach liefern, ohne Postbox zu einem Mailkonto zu machen.",
|
||||
"body": (
|
||||
"Mail stellt eine optionale, idempotente Bridge-Capability bereit. Ein konfigurierter Aufrufer übergibt ein exaktes Mailprofil, Ordner, UIDVALIDITY, UID, die rohe Beobachtung "
|
||||
"und ein typisiertes Postbox-Ziel. Mail liest begrenzte Kopfzeilen, Klartext, Beteiligte und Anlagennachweise; Postbox besitzt Zielauflösung, Zustellung, "
|
||||
"Aufbewahrung, Belege und Zugriff. Die Bridge speichert keine Zugangsdaten in Postbox und behandelt die Kontoanmeldung niemals als Postfachmitgliedschaft."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "guide", "help_contexts": ["mail.postbox-bridge"]},
|
||||
order=36,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.configuration-package.smtp-profile",
|
||||
title="Provision SMTP profiles from deployment receipts",
|
||||
summary="Review and apply an idempotent Mail profile without moving plaintext credentials through a package.",
|
||||
body=(
|
||||
"The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. "
|
||||
"Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. "
|
||||
"Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, "
|
||||
"and an unchanged second apply is a no-op. SMTP reachability remains a separate Mail profile test."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("mail_admin", "administrator", "operator"),
|
||||
related_modules=("core", "ops", "access"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail", "access"),
|
||||
any_scopes=("admin:settings:read", "system:settings:read"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Configuration packages",
|
||||
href="/admin?section=configuration-packages",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Mail handbook",
|
||||
href="govoplan-mail/docs/MAIL_HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "SMTP-Profile aus Bereitstellungsnachweisen einrichten",
|
||||
"summary": "Ein idempotentes Mail-Profil prüfen und anwenden, ohne Klartext-Zugangsdaten durch ein Konfigurationspaket zu übertragen.",
|
||||
"body": (
|
||||
"Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. "
|
||||
"Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. "
|
||||
"Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, "
|
||||
"und eine unveränderte zweite Anwendung bleibt wirkungslos. Die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=configuration-packages",
|
||||
"help_contexts": ["admin.configuration-packages", "mail.admin.profiles"],
|
||||
},
|
||||
order=4,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.quick-access-and-product-area",
|
||||
title="Mail in Communication and Messages",
|
||||
summary="Use Mail in the Communication product area and the shared Messages Quick Access drawer.",
|
||||
body=(
|
||||
"Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface "
|
||||
"appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. "
|
||||
"Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or "
|
||||
"provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. "
|
||||
"Compose deliberately launches the user's configured mail application because GovOPlaN Mail's mailbox is "
|
||||
"read-only; it neither selects a GovOPlaN transport profile nor claims a GovOPlaN delivery. The shared drawer "
|
||||
"does not merge channel state, credentials, custody, delivery semantics, or authorization."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("mail_user", "mail_admin", "administrator"),
|
||||
related_modules=("quick_access", "views", "postbox"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Mail in Kommunikation und Nachrichten",
|
||||
"summary": "Mail im Produktbereich Kommunikation und in der gemeinsamen Schnellzugriffseinblendung Nachrichten verwenden.",
|
||||
"body": (
|
||||
"Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte "
|
||||
"Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. "
|
||||
"Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet "
|
||||
"den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den "
|
||||
"versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, "
|
||||
"da das GovOPlaN-Mail-Postfach nur lesend arbeitet; dabei wird weder ein GovOPlaN-Transportprofil gewählt "
|
||||
"noch eine GovOPlaN-Zustellung behauptet. Die gemeinsame Darstellung führt weder Kanalzustand noch "
|
||||
"Zugangsdaten, Verwahrung, Zustelllogik oder Berechtigungen zusammen."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "reference", "help_contexts": ["mail.quick_access.messages"]},
|
||||
order=37,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.search.mailbox-messages",
|
||||
title="Search authorized mailbox messages",
|
||||
@@ -473,6 +638,44 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.profile-standard-folder-mappings",
|
||||
title="Map standard folders for an IMAP profile",
|
||||
summary="Store Inbox, Sent, Drafts, Trash, Archive, and Junk mappings on the reusable Mail profile and populate them from bounded IMAP discovery.",
|
||||
body=(
|
||||
"Folder names are profile/server metadata, not Campaign settings. An authorized profile administrator may enter names directly or use folder discovery; empty mappings retain automatic behavior. Existing imap.sent_folder values are presented and persisted as the Sent mapping, while the compatibility field remains synchronized for consumers that still read it. A Campaign-specific Sent-folder override remains authoritative for that Campaign and is not rewritten by profile discovery. Folder discovery lists provider-visible names without creating, renaming, moving, or deleting remote folders."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_admin", "campaign_admin", "mail_user"),
|
||||
order=40,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
required_scopes=("mail:profile:read",),
|
||||
any_scopes=("mail:profile:write", "mail:profile:write_own", "mail:profile:test"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("campaigns",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
"screen": "Mail profiles",
|
||||
"section": "IMAP standard folder mappings",
|
||||
"help_contexts": ["mail.profiles", "mail.admin.profiles"],
|
||||
"steps": [
|
||||
"Open an editable IMAP server in a reusable Mail profile.",
|
||||
"Choose Detect folders to load the provider-visible folder names, or enter exact names manually.",
|
||||
"Apply the detected mappings, review every role, and leave uncertain roles empty for automatic behavior.",
|
||||
"Save the profile and reload it to verify the mappings were retained.",
|
||||
],
|
||||
"verification": "Confirm legacy Sent-only profiles show the same effective Sent mapping, and confirm a Campaign-local Sent override remains unchanged.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.bounce-processing",
|
||||
title="Delivery-status and bounce processing",
|
||||
@@ -596,8 +799,8 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="mail.workflow.read-mailbox",
|
||||
title="Read a permitted mailbox without changing it",
|
||||
summary="Choose an IMAP-enabled profile, browse folders, and inspect bounded message 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. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
|
||||
summary="Choose an IMAP-enabled profile, browse synchronized folders, and inspect bounded message 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. Lists expose provider read/unread flags and live/cache/refresh provenance without mutating them. Message HTML is isolated and sanitized, while attachments, inline references, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("mail_user",),
|
||||
@@ -624,8 +827,9 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"steps": [
|
||||
"Open Mail and choose an authorized IMAP-enabled profile.",
|
||||
"Select a folder and page through its bounded message index.",
|
||||
"Open only the message needed for the task and close it when finished.",
|
||||
"Select a folder, review the live/cached synchronization label, and page or filter its bounded message index.",
|
||||
"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.",
|
||||
],
|
||||
"outcome": "The required message was inspected without changing provider mailbox state.",
|
||||
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from email import policy
|
||||
from email.message import Message
|
||||
from email.parser import BytesParser
|
||||
from email.utils import getaddresses
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.mail import (
|
||||
MailPostboxBridgeProvider,
|
||||
MailPostboxBridgeRequest,
|
||||
MailPostboxBridgeResult,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
postbox_delivery_provider,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
from govoplan_mail.backend.runtime import configure_runtime, get_registry
|
||||
|
||||
|
||||
MAX_BRIDGE_MESSAGE_BYTES = 50 * 1024 * 1024
|
||||
MAX_POSTBOX_BODY_CHARS = 500_000
|
||||
|
||||
|
||||
class MailPostboxBridgeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MailPostboxBridge(MailPostboxBridgeProvider):
|
||||
"""Translate immutable IMAP observations into native Postbox delivery."""
|
||||
|
||||
def bridge_message(
|
||||
self,
|
||||
session: object,
|
||||
request: MailPostboxBridgeRequest,
|
||||
) -> MailPostboxBridgeResult:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Mail Postbox bridging requires a SQLAlchemy session.")
|
||||
if not isinstance(request.target, PostboxTargetRef):
|
||||
raise MailPostboxBridgeError("A typed Postbox target is required.")
|
||||
if len(request.raw_message) > MAX_BRIDGE_MESSAGE_BYTES:
|
||||
raise MailPostboxBridgeError("Mail message exceeds the Postbox bridge limit.")
|
||||
profile = session.get(MailServerProfile, request.profile_id)
|
||||
if profile is None or profile.tenant_id != request.tenant_id:
|
||||
raise MailPostboxBridgeError("Mail profile not found.")
|
||||
folder = request.folder.strip()
|
||||
uid = request.uid.strip()
|
||||
uidvalidity = request.uidvalidity.strip()
|
||||
if not folder or not uid or not uidvalidity:
|
||||
raise MailPostboxBridgeError(
|
||||
"Mail folder, UIDVALIDITY, and immutable UID are required."
|
||||
)
|
||||
try:
|
||||
message = BytesParser(policy=policy.default).parsebytes(request.raw_message)
|
||||
except Exception as exc:
|
||||
raise MailPostboxBridgeError("Mail message could not be parsed.") from exc
|
||||
|
||||
provider = postbox_delivery_provider(get_registry())
|
||||
if provider is None:
|
||||
raise MailPostboxBridgeError("Postbox delivery is not available.")
|
||||
source_digest = hashlib.sha256(request.raw_message).hexdigest()
|
||||
source_key = hashlib.sha256(
|
||||
f"{request.tenant_id}\0{request.profile_id}\0{folder}\0{uidvalidity}\0{uid}".encode("utf-8")
|
||||
).hexdigest()
|
||||
result = provider.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id=request.tenant_id,
|
||||
target=request.target,
|
||||
producer_module="mail",
|
||||
producer_resource_type="imap_message",
|
||||
producer_resource_id=source_key,
|
||||
idempotency_key=f"mail-postbox:{source_key}:{source_digest}",
|
||||
subject=_header(message, "Subject") or "(No subject)",
|
||||
body_text=_plain_text_body(message),
|
||||
sender_label=_header(message, "From"),
|
||||
classification=request.classification,
|
||||
participants=_participants(message),
|
||||
attachments=_attachments(
|
||||
message,
|
||||
profile_id=request.profile_id,
|
||||
folder=folder,
|
||||
uidvalidity=uidvalidity,
|
||||
uid=uid,
|
||||
),
|
||||
metadata={
|
||||
**dict(request.metadata),
|
||||
"transport": "mail-imap",
|
||||
"mail_profile_id": request.profile_id,
|
||||
"mailbox_folder": folder,
|
||||
"mailbox_uidvalidity": uidvalidity,
|
||||
"mailbox_uid": uid,
|
||||
"rfc_message_id": _header(message, "Message-ID"),
|
||||
"raw_sha256": source_digest,
|
||||
},
|
||||
),
|
||||
)
|
||||
return MailPostboxBridgeResult(
|
||||
postbox_id=result.postbox_id,
|
||||
message_id=result.message_id,
|
||||
delivery_id=result.delivery_id,
|
||||
duplicate=result.duplicate,
|
||||
source_digest=source_digest,
|
||||
)
|
||||
|
||||
|
||||
def _header(message: Message, name: str) -> str | None:
|
||||
value = " ".join(str(message.get(name) or "").split())
|
||||
return value[:1000] or None
|
||||
|
||||
|
||||
def _plain_text_body(message: Message) -> str | None:
|
||||
candidates = message.walk() if message.is_multipart() else (message,)
|
||||
for part in candidates:
|
||||
if part.get_content_type() != "text/plain":
|
||||
continue
|
||||
if part.get_content_disposition() == "attachment":
|
||||
continue
|
||||
try:
|
||||
value = part.get_content()
|
||||
except Exception:
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
value = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
normalized = str(value).strip()
|
||||
if normalized:
|
||||
return normalized[:MAX_POSTBOX_BODY_CHARS]
|
||||
return None
|
||||
|
||||
|
||||
def _participants(message: Message) -> tuple[PostboxParticipantRef, ...]:
|
||||
result: list[PostboxParticipantRef] = []
|
||||
for kind, headers in (
|
||||
("sender", ("From",)),
|
||||
("to", ("To",)),
|
||||
("cc", ("Cc",)),
|
||||
("bcc", ("Bcc",)),
|
||||
):
|
||||
for name, address in getaddresses(
|
||||
[str(value) for header in headers for value in message.get_all(header, [])]
|
||||
):
|
||||
clean_address = address.strip()[:500]
|
||||
if not clean_address:
|
||||
continue
|
||||
result.append(
|
||||
PostboxParticipantRef(
|
||||
kind=kind,
|
||||
reference_type="external_email",
|
||||
label=name.strip()[:500] or None,
|
||||
address=clean_address,
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _attachments(
|
||||
message: Message,
|
||||
*,
|
||||
profile_id: str,
|
||||
folder: str,
|
||||
uidvalidity: str,
|
||||
uid: str,
|
||||
) -> tuple[PostboxAttachmentRef, ...]:
|
||||
result: list[PostboxAttachmentRef] = []
|
||||
for index, part in enumerate(message.walk()):
|
||||
filename = part.get_filename()
|
||||
if part.get_content_disposition() != "attachment" and not filename:
|
||||
continue
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
reference_id = hashlib.sha256(
|
||||
f"{profile_id}\0{folder}\0{uidvalidity}\0{uid}\0{index}\0{digest}".encode("utf-8")
|
||||
).hexdigest()
|
||||
result.append(
|
||||
PostboxAttachmentRef(
|
||||
reference_type="mail_attachment",
|
||||
reference_id=reference_id,
|
||||
name=str(filename or f"attachment-{index + 1}")[:1000],
|
||||
media_type=part.get_content_type(),
|
||||
size_bytes=len(payload),
|
||||
digest=digest,
|
||||
metadata={
|
||||
"mail_profile_id": profile_id,
|
||||
"mailbox_folder": folder,
|
||||
"mailbox_uidvalidity": uidvalidity,
|
||||
"mailbox_uid": uid,
|
||||
"mime_part_index": index,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def create_postbox_bridge(context: ModuleContext) -> MailPostboxBridge:
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
return MailPostboxBridge()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MailPostboxBridge",
|
||||
"MailPostboxBridgeError",
|
||||
"create_postbox_bridge",
|
||||
]
|
||||
@@ -1179,6 +1179,7 @@ def _mailbox_folder_response(
|
||||
message=f"Found {len(folders)} IMAP folder(s).",
|
||||
folders=folders,
|
||||
detected_sent_folder=result.detected_sent_folder,
|
||||
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
|
||||
from_cache=from_cache,
|
||||
refreshing=refreshing,
|
||||
indexed_at=indexed_at,
|
||||
@@ -2276,7 +2277,7 @@ def update_profile(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
config=smtp_config.model_dump(mode="json"),
|
||||
config=smtp_config.model_dump(mode="json", exclude_none=True),
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
imap_server = None
|
||||
@@ -2286,7 +2287,7 @@ def update_profile(
|
||||
profile=profile,
|
||||
protocol="imap",
|
||||
config=(
|
||||
imap_config.model_dump(mode="json")
|
||||
imap_config.model_dump(mode="json", exclude_none=True)
|
||||
if imap_config is not None and not payload.clear_imap
|
||||
else None
|
||||
),
|
||||
@@ -3063,6 +3064,7 @@ def list_imap_folder_settings(
|
||||
message=f"Found {len(folders)} IMAP folder(s).",
|
||||
folders=folders,
|
||||
detected_sent_folder=result.detected_sent_folder,
|
||||
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
|
||||
)
|
||||
except Exception as exc:
|
||||
return MailImapFolderListResponse(
|
||||
|
||||
@@ -370,6 +370,7 @@ class MailImapFolderListResponse(BaseModel):
|
||||
message: str
|
||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||
detected_sent_folder: str | None = None
|
||||
detected_folder_mappings: dict[str, str] = Field(default_factory=dict)
|
||||
from_cache: bool = False
|
||||
refreshing: bool = False
|
||||
indexed_at: datetime | None = None
|
||||
|
||||
@@ -103,6 +103,7 @@ class ImapFolderListResult:
|
||||
security: str
|
||||
folders: list[ImapMailboxInfo]
|
||||
detected_sent_folder: str | None = None
|
||||
detected_folder_mappings: dict[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -317,25 +318,47 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
|
||||
return None
|
||||
|
||||
|
||||
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||
for name, flags in parsed:
|
||||
if "\\sent" in flags or "\\sentmail" in flags:
|
||||
return name
|
||||
_STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = {
|
||||
"inbox": ("\\inbox",),
|
||||
"sent": ("\\sent", "\\sentmail"),
|
||||
"drafts": ("\\drafts",),
|
||||
"trash": ("\\trash",),
|
||||
"archive": ("\\archive", "\\all"),
|
||||
"junk": ("\\junk", "\\spam"),
|
||||
}
|
||||
|
||||
common_names = [
|
||||
"Sent",
|
||||
"Sent Items",
|
||||
"Sent Messages",
|
||||
"Gesendet",
|
||||
"Gesendete Elemente",
|
||||
"INBOX.Sent",
|
||||
"INBOX/Sent",
|
||||
]
|
||||
names = {name.lower(): name for name, _ in parsed}
|
||||
for candidate in common_names:
|
||||
if candidate.lower() in names:
|
||||
return names[candidate.lower()]
|
||||
return None
|
||||
_STANDARD_FOLDER_NAMES: dict[str, tuple[str, ...]] = {
|
||||
"inbox": ("INBOX", "Posteingang"),
|
||||
"sent": ("Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent"),
|
||||
"drafts": ("Drafts", "Entwürfe", "Entwuerfe"),
|
||||
"trash": ("Trash", "Deleted Items", "Gelöscht", "Geloescht", "Papierkorb"),
|
||||
"archive": ("Archive", "Archives", "Archiv"),
|
||||
"junk": ("Junk", "Spam", "Junk Email", "Unerwünscht", "Unerwuenscht"),
|
||||
}
|
||||
|
||||
|
||||
def _detect_standard_folder_mappings(parsed: list[tuple[str, set[str]]]) -> dict[str, str]:
|
||||
detected: dict[str, str] = {}
|
||||
for role, accepted_flags in _STANDARD_FOLDER_FLAGS.items():
|
||||
for name, flags in parsed:
|
||||
if any(flag in flags for flag in accepted_flags):
|
||||
detected[role] = name
|
||||
break
|
||||
|
||||
names = {name.casefold(): name for name, _ in parsed}
|
||||
for role, candidates in _STANDARD_FOLDER_NAMES.items():
|
||||
if role in detected:
|
||||
continue
|
||||
for candidate in candidates:
|
||||
match = names.get(candidate.casefold())
|
||||
if match:
|
||||
detected[role] = match
|
||||
break
|
||||
return detected
|
||||
|
||||
|
||||
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||
return _detect_standard_folder_mappings(parsed).get("sent")
|
||||
|
||||
|
||||
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
|
||||
@@ -402,12 +425,18 @@ def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
name = str(item["name"])
|
||||
count = sum(1 for record in records if _mock_folder_matches(record, name))
|
||||
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
|
||||
parsed = [
|
||||
(str(item["name"]), {str(flag).lower() for flag in item.get("flags") or []})
|
||||
for item in MOCK_IMAP_FOLDERS
|
||||
]
|
||||
detected = _detect_standard_folder_mappings(parsed)
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folders=folders,
|
||||
detected_sent_folder="Sent",
|
||||
detected_sent_folder=detected.get("sent"),
|
||||
detected_folder_mappings=detected,
|
||||
)
|
||||
|
||||
|
||||
@@ -438,12 +467,14 @@ def _list_imap_folders_on_client(
|
||||
)
|
||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
||||
|
||||
detected = _detect_standard_folder_mappings(parsed)
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=security,
|
||||
folders=folders,
|
||||
detected_sent_folder=_detect_sent_folder(parsed),
|
||||
detected_sent_folder=detected.get("sent"),
|
||||
detected_folder_mappings=detected,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ def initialize_profile_hierarchy(
|
||||
for protocol, value in (("smtp", smtp), ("imap", imap)):
|
||||
if value is None or protocol in existing:
|
||||
continue
|
||||
raw = value.model_dump(mode="json") if hasattr(value, "model_dump") else dict(value)
|
||||
raw = value.model_dump(mode="json", exclude_none=True) if hasattr(value, "model_dump") else dict(value)
|
||||
credentials = {
|
||||
"username": raw.pop("username", None),
|
||||
"password": raw.pop("password", None),
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPreflightContext,
|
||||
)
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_core.security.credential_envelopes import CredentialEnvelope
|
||||
from govoplan_mail.backend.configuration_provider import (
|
||||
MAIL_CONFIGURATION_CAPABILITY,
|
||||
SqlMailConfigurationProvider,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailProfilePolicy,
|
||||
MailServerCredentialBinding,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.manifest import manifest
|
||||
|
||||
|
||||
def _receipt():
|
||||
return infrastructure_capability_receipt_from_mapping(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"installation_id": "mail-provider-test",
|
||||
"profile": "evaluation",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "mail.smtp",
|
||||
"label": "SMTP delivery",
|
||||
"state": "available_unconfigured",
|
||||
"source": "installer-managed-test",
|
||||
"detail": "GreenMail is available for profile binding.",
|
||||
"endpoint": {
|
||||
"scheme": "smtp",
|
||||
"host": "test-mail",
|
||||
"port": 3025,
|
||||
},
|
||||
"secret_refs": [],
|
||||
"dependent_modules": ["mail"],
|
||||
}
|
||||
],
|
||||
"post_install_tasks": [
|
||||
{
|
||||
"id": "mail.smtp-profile",
|
||||
"resume_key": "mail-provider-test:mail.smtp-profile:v1",
|
||||
"capability_id": "mail.smtp",
|
||||
"state": "pending",
|
||||
"owner_module": "mail",
|
||||
"summary": "Create a Mail SMTP profile.",
|
||||
"required_inputs": ["credential envelope reference when required"],
|
||||
"secret_boundary": "credential-envelope-reference-only",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _external_receipt(*, credential_required: bool = False):
|
||||
return infrastructure_capability_receipt_from_mapping(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"installation_id": "mail-provider-external",
|
||||
"profile": "production",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "mail.smtp",
|
||||
"label": "SMTP delivery",
|
||||
"state": "available_unconfigured",
|
||||
"source": "operator-supplied",
|
||||
"detail": "An external relay needs reviewed Mail configuration.",
|
||||
"endpoint": {},
|
||||
"secret_refs": (
|
||||
["env:SMTP_CREDENTIAL_ENVELOPE_REF"]
|
||||
if credential_required
|
||||
else []
|
||||
),
|
||||
"dependent_modules": ["mail"],
|
||||
}
|
||||
],
|
||||
"post_install_tasks": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MailConfigurationProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tempdir = tempfile.TemporaryDirectory(prefix="govoplan-mail-config-")
|
||||
self.addCleanup(self.tempdir.cleanup)
|
||||
database_path = Path(self.tempdir.name) / "mail.sqlite3"
|
||||
self.engine = create_engine(f"sqlite:///{database_path}")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(
|
||||
access_models.User.__table__,
|
||||
SystemSettings.__table__,
|
||||
CredentialEnvelope.__table__,
|
||||
MailServerProfile.__table__,
|
||||
MailServerEndpoint.__table__,
|
||||
MailServerCredentialBinding.__table__,
|
||||
MailProfilePolicy.__table__,
|
||||
),
|
||||
)
|
||||
configure_database(
|
||||
f"sqlite:///{database_path}",
|
||||
engine=self.engine,
|
||||
dispose_previous=True,
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
session.add(
|
||||
MailProfilePolicy(
|
||||
id="tenant-policy",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
policy={},
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
CredentialEnvelope(
|
||||
id="credential-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="SMTP credential",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "mailer"},
|
||||
secret_data_encrypted="encrypted-outside-package",
|
||||
secret_keys=["password"],
|
||||
allowed_modules=["mail"],
|
||||
allowed_server_refs=[],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.addCleanup(self._cleanup_database)
|
||||
self.provider = SqlMailConfigurationProvider()
|
||||
self.context = ConfigurationPreflightContext(
|
||||
tenant_id="tenant-1",
|
||||
operator_user_id=None,
|
||||
infrastructure_receipt=_receipt(),
|
||||
)
|
||||
|
||||
def _cleanup_database(self) -> None:
|
||||
reset_database()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None:
|
||||
self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories)
|
||||
description = self.provider.describe()
|
||||
self.assertEqual(("smtp_profile",), description.fragment_types)
|
||||
|
||||
def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={"credential_envelope_id": "credential-1"},
|
||||
)
|
||||
|
||||
first_plan = self.provider.preflight(fragment, self.context)
|
||||
self.assertEqual("create", first_plan.plan[0].action)
|
||||
self.assertFalse(
|
||||
any(item.severity == "blocker" for item in first_plan.diagnostics)
|
||||
)
|
||||
first_apply = self.provider.apply(fragment, {}, self.context)
|
||||
self.assertIn("test-smtp", first_apply.created_refs)
|
||||
|
||||
second_plan = self.provider.preflight(fragment, self.context)
|
||||
second_apply = self.provider.apply(fragment, {}, self.context)
|
||||
|
||||
self.assertEqual("skip", second_plan.plan[0].action)
|
||||
self.assertEqual({}, second_apply.created_refs)
|
||||
self.assertEqual({}, second_apply.updated_refs)
|
||||
with self.SessionLocal() as session:
|
||||
profiles = session.scalars(select(MailServerProfile)).all()
|
||||
servers = session.scalars(select(MailServerEndpoint)).all()
|
||||
bindings = session.scalars(select(MailServerCredentialBinding)).all()
|
||||
self.assertEqual(1, len(profiles))
|
||||
self.assertEqual("test-mail", profiles[0].smtp_config["host"])
|
||||
self.assertEqual("plain", profiles[0].smtp_config["security"])
|
||||
self.assertEqual(1, len(servers))
|
||||
self.assertEqual("test-mail", servers[0].config["host"])
|
||||
self.assertEqual(1, len(bindings))
|
||||
self.assertEqual("credential-1", bindings[0].credential_id)
|
||||
|
||||
def test_conflicting_existing_profile_is_preserved_by_default(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={},
|
||||
)
|
||||
self.provider.apply(fragment, {}, self.context)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||
session.commit()
|
||||
|
||||
plan = self.provider.preflight(fragment, self.context)
|
||||
result = self.provider.apply(fragment, {}, self.context)
|
||||
|
||||
self.assertEqual("blocked", plan.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail_configuration_conflict",
|
||||
{item.code for item in result.diagnostics},
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
self.assertEqual("manually-changed.example.test", server.config["host"])
|
||||
|
||||
def test_explicit_conflict_update_reconciles_then_becomes_noop(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={},
|
||||
)
|
||||
self.provider.apply(fragment, {}, self.context)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||
session.commit()
|
||||
update_fragment = replace(
|
||||
fragment,
|
||||
payload={"on_conflict": "update"},
|
||||
)
|
||||
|
||||
plan = self.provider.preflight(update_fragment, self.context)
|
||||
result = self.provider.apply(update_fragment, {}, self.context)
|
||||
settled = self.provider.preflight(update_fragment, self.context)
|
||||
|
||||
self.assertEqual("update", plan.plan[0].action)
|
||||
self.assertIn("test-smtp", result.updated_refs)
|
||||
self.assertEqual("skip", settled.plan[0].action)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
self.assertEqual("test-mail", server.config["host"])
|
||||
|
||||
def test_inline_credentials_are_rejected(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
payload={
|
||||
"smtp": {
|
||||
"host": "test-mail",
|
||||
"port": 3025,
|
||||
"security": "plain",
|
||||
"password": "must-not-cross-boundary",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
plan = self.provider.preflight(fragment, self.context)
|
||||
|
||||
self.assertEqual("blocked", plan.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail_configuration_secret_forbidden",
|
||||
{item.code for item in plan.diagnostics},
|
||||
)
|
||||
|
||||
def test_external_relay_collects_missing_non_secret_inputs(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="external-smtp",
|
||||
payload={},
|
||||
)
|
||||
missing = self.provider.preflight(
|
||||
fragment,
|
||||
replace(self.context, infrastructure_receipt=_external_receipt()),
|
||||
)
|
||||
ready = self.provider.preflight(
|
||||
fragment,
|
||||
replace(
|
||||
self.context,
|
||||
infrastructure_receipt=_external_receipt(),
|
||||
supplied_data={
|
||||
"mail.smtp.external-smtp.host": "smtp.example.test",
|
||||
"mail.smtp.external-smtp.port": 587,
|
||||
"mail.smtp.external-smtp.security": "starttls",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", missing.plan[0].action)
|
||||
self.assertEqual(
|
||||
{
|
||||
"mail.smtp.external-smtp.host",
|
||||
"mail.smtp.external-smtp.port",
|
||||
"mail.smtp.external-smtp.security",
|
||||
},
|
||||
{item.key for item in missing.required_data if item.required},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
def test_receipt_secret_reference_requires_credential_envelope_reference(
|
||||
self,
|
||||
) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="authenticated-smtp",
|
||||
payload={
|
||||
"smtp": {
|
||||
"host": "smtp.example.test",
|
||||
"port": 587,
|
||||
"security": "starttls",
|
||||
}
|
||||
},
|
||||
)
|
||||
context = replace(
|
||||
self.context,
|
||||
infrastructure_receipt=_external_receipt(credential_required=True),
|
||||
)
|
||||
|
||||
missing = self.provider.preflight(fragment, context)
|
||||
ready = self.provider.preflight(
|
||||
ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="authenticated-smtp",
|
||||
payload={
|
||||
**fragment.payload,
|
||||
"credential_envelope_id": "credential-1",
|
||||
},
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", missing.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail.smtp.authenticated-smtp.credential_envelope_id",
|
||||
{item.key for item in missing.required_data if item.required},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
def test_system_profile_requires_system_configuration_authority(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
payload={"profile": {"scope_type": "system"}},
|
||||
)
|
||||
|
||||
blocked = self.provider.preflight(fragment, self.context)
|
||||
ready = self.provider.preflight(
|
||||
fragment,
|
||||
replace(
|
||||
self.context,
|
||||
operator_scopes=frozenset({"system:settings:write"}),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", blocked.plan[0].action)
|
||||
self.assertIn(
|
||||
"system_configuration_authority_required",
|
||||
{item.code for item in blocked.diagnostics},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig
|
||||
from govoplan_mail.backend.sending.imap import (
|
||||
ImapAppendError,
|
||||
ImapConfigurationError,
|
||||
_detect_standard_folder_mappings,
|
||||
_detect_sent_folder,
|
||||
_extract_mailbox_name,
|
||||
_fetch_message_by_uid,
|
||||
@@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
|
||||
_select_readonly,
|
||||
_sequence_set,
|
||||
append_message_to_sent,
|
||||
list_imap_folders,
|
||||
list_imap_messages,
|
||||
list_imap_uids_since,
|
||||
)
|
||||
@@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase):
|
||||
"Gesendet",
|
||||
)
|
||||
|
||||
def test_detects_all_standard_folder_roles_by_flag_then_name(self):
|
||||
self.assertEqual(
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Gesendete Elemente",
|
||||
"drafts": "Entwürfe",
|
||||
"trash": "Deleted",
|
||||
"archive": "All Mail",
|
||||
"junk": "Spam",
|
||||
},
|
||||
_detect_standard_folder_mappings(
|
||||
[
|
||||
("INBOX", set()),
|
||||
("Gesendete Elemente", set()),
|
||||
("Entwürfe", set()),
|
||||
("Deleted", {"\\trash"}),
|
||||
("All Mail", {"\\all"}),
|
||||
("Spam", {"\\junk"}),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def test_mock_folder_listing_exposes_detected_standard_mappings(self):
|
||||
result = list_imap_folders(
|
||||
imap_config=ImapConfig(host="mock.imap.local"), include_status=False
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent",
|
||||
"drafts": "Drafts",
|
||||
"trash": "Trash",
|
||||
"archive": "Archive",
|
||||
},
|
||||
result.detected_folder_mappings,
|
||||
)
|
||||
self.assertEqual("Sent", result.detected_sent_folder)
|
||||
|
||||
|
||||
class ImapMessagePaginationTests(unittest.TestCase):
|
||||
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
|
||||
|
||||
@@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
||||
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
|
||||
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
|
||||
|
||||
def test_apply_transport_update_persists_standard_folder_mappings(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-folders",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_config={"host": "smtp.example.org"},
|
||||
smtp_username=None,
|
||||
smtp_password_encrypted=None,
|
||||
smtp_transport_revision="smtp-before",
|
||||
imap_config={"host": "imap.example.org", "sent_folder": "Legacy Sent"},
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="imap-before",
|
||||
)
|
||||
session = SimpleNamespace(flush=lambda: None)
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index"):
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
smtp=None,
|
||||
imap=ImapConfig(
|
||||
host="imap.example.org",
|
||||
folder_mappings={
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent Items",
|
||||
"drafts": "Drafts",
|
||||
},
|
||||
),
|
||||
clear_imap=False,
|
||||
)
|
||||
|
||||
self.assertEqual(profile.imap_config["sent_folder"], "Sent Items")
|
||||
self.assertEqual(
|
||||
profile.imap_config["folder_mappings"],
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent Items",
|
||||
"drafts": "Drafts",
|
||||
},
|
||||
)
|
||||
|
||||
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.mail import MailPostboxBridgeRequest
|
||||
from govoplan_core.core.postbox import PostboxTargetRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
from govoplan_mail.backend.postbox_bridge import MailPostboxBridge
|
||||
|
||||
|
||||
RAW_MESSAGE = b"""From: Ada Example <ada@example.test>
|
||||
To: Clerk <clerk@example.test>
|
||||
Cc: Archive <archive@example.test>
|
||||
Message-ID: <bridge-1@example.test>
|
||||
Subject: Submitted evidence
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary=bridge
|
||||
|
||||
--bridge
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Please process the attached evidence.
|
||||
--bridge
|
||||
Content-Type: application/pdf
|
||||
Content-Disposition: attachment; filename=evidence.pdf
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
UERG
|
||||
--bridge--
|
||||
"""
|
||||
|
||||
|
||||
class _DeliveryProvider:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def deliver(self, session, request):
|
||||
del session
|
||||
self.requests.append(request)
|
||||
return SimpleNamespace(
|
||||
postbox_id="postbox-1",
|
||||
message_id="message-1",
|
||||
delivery_id="delivery-1",
|
||||
duplicate=len(self.requests) > 1,
|
||||
)
|
||||
|
||||
|
||||
class MailPostboxBridgeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
access_users = Base.metadata.tables.get("access_users")
|
||||
if access_users is None:
|
||||
access_users = Table(
|
||||
"access_users",
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[access_users, MailServerProfile.__table__],
|
||||
)
|
||||
self.Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.Session() as session:
|
||||
session.add(
|
||||
MailServerProfile(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Inbound",
|
||||
slug="inbound",
|
||||
smtp_config={},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.addCleanup(self.engine.dispose)
|
||||
|
||||
def test_bridge_parses_bounded_content_and_delegates_idempotently(self) -> None:
|
||||
delivery = _DeliveryProvider()
|
||||
request = MailPostboxBridgeRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id="postbox-1"),
|
||||
profile_id="profile-1",
|
||||
folder="INBOX",
|
||||
uid="42",
|
||||
uidvalidity="20260807",
|
||||
raw_message=RAW_MESSAGE,
|
||||
)
|
||||
with self.Session() as session, patch(
|
||||
"govoplan_mail.backend.postbox_bridge.postbox_delivery_provider",
|
||||
return_value=delivery,
|
||||
):
|
||||
first = MailPostboxBridge().bridge_message(session, request)
|
||||
second = MailPostboxBridge().bridge_message(session, request)
|
||||
MailPostboxBridge().bridge_message(
|
||||
session,
|
||||
replace(request, uidvalidity="20260808"),
|
||||
)
|
||||
|
||||
self.assertFalse(first.duplicate)
|
||||
self.assertTrue(second.duplicate)
|
||||
self.assertEqual(first.source_digest, second.source_digest)
|
||||
self.assertEqual(
|
||||
delivery.requests[0].idempotency_key,
|
||||
delivery.requests[1].idempotency_key,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
delivery.requests[0].idempotency_key,
|
||||
delivery.requests[2].idempotency_key,
|
||||
)
|
||||
bridged = delivery.requests[0]
|
||||
self.assertEqual("Submitted evidence", bridged.subject)
|
||||
self.assertIn("Please process", bridged.body_text)
|
||||
self.assertEqual(
|
||||
["sender", "to", "cc"],
|
||||
[participant.kind for participant in bridged.participants],
|
||||
)
|
||||
self.assertEqual(1, len(bridged.attachments))
|
||||
self.assertEqual("evidence.pdf", bridged.attachments[0].name)
|
||||
self.assertEqual("mail_attachment", bridged.attachments[0].reference_type)
|
||||
self.assertEqual("<bridge-1@example.test>", bridged.metadata["rfc_message_id"])
|
||||
self.assertEqual("20260807", bridged.metadata["mailbox_uidvalidity"])
|
||||
self.assertNotIn("raw_message", bridged.metadata)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -22,11 +22,17 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/);
|
||||
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
|
||||
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
|
||||
assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/);
|
||||
assert.match(profiles, /folder_mappings: draft\.imapFolderMappings/);
|
||||
assert.match(profiles, /listMailProfileImapFolders[\s\S]*listImapFolders/);
|
||||
assert.match(profiles, /onLookupImapFolders=\{\(\) => void runImapFolderLookup\(\)\}/);
|
||||
assert.match(profiles, /imapFolderLookupResult=\{imapFolderResult\}/);
|
||||
|
||||
assert.match(mailbox, /ActionBlockerHint/);
|
||||
assert.match(mailbox, /DocumentationHelpLink/);
|
||||
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/);
|
||||
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
|
||||
|
||||
assert.match(bounces, /DocumentationHelpLink/);
|
||||
@@ -38,7 +44,7 @@ assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}`, /window\.(?:alert|con
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
||||
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: 1250px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
||||
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/);
|
||||
|
||||
for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
|
||||
|
||||
@@ -23,3 +23,15 @@ assert(
|
||||
!styles.includes(".mailbox-search-field button"),
|
||||
"Mail must not redefine the central icon-button appearance"
|
||||
);
|
||||
assert(
|
||||
source.includes("isMailboxMessageRead(message.flags)"),
|
||||
"mailbox rows must present provider-derived read/unread state"
|
||||
);
|
||||
assert(
|
||||
source.includes("mailboxSyncState(messageProvenance)"),
|
||||
"mailbox lists must present synchronization provenance"
|
||||
);
|
||||
assert(
|
||||
styles.includes(".mailbox-message-row.is-unread") && styles.includes(".mailbox-sync-provenance"),
|
||||
"read state and synchronization provenance must retain focused responsive styling"
|
||||
);
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
import { FormGrid, ContentGrid,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
PageScrollViewport,
|
||||
PageTitle,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
@@ -194,33 +192,37 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
];
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
|
||||
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
|
||||
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label="Loading bounce processing">
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Watched mailboxes">
|
||||
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
||||
</Card>
|
||||
<Card title="Delivery-status observations">
|
||||
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
||||
</Card>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
<>
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
title="Bounce processing"
|
||||
description="Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands."
|
||||
actions={<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), disabled: Boolean(pageMutationBlocker), disabledReason: pageMutationBlocker }}
|
||||
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />}
|
||||
createAction={<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>}
|
||||
/>}
|
||||
loading={loading}
|
||||
loadingLabel="Loading bounce processing"
|
||||
error={error}
|
||||
success={message}
|
||||
documentationType="admin"
|
||||
>
|
||||
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||
<Card title="Watched mailboxes">
|
||||
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
||||
</Card>
|
||||
<Card title="Delivery-status observations">
|
||||
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
</PageLayout>
|
||||
|
||||
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
|
||||
<div className="form-grid">
|
||||
<FormGrid columns={1} collapseAt="standard" className="">
|
||||
{imapProfiles.length === 0 &&
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
@@ -242,7 +244,7 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
|
||||
</FormField>
|
||||
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -254,6 +256,6 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
busy={Boolean(busy)}
|
||||
onCancel={() => setDeleteSource(null)}
|
||||
onConfirm={() => void removeSource()} />
|
||||
</PageScrollViewport>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||
import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
deactivateMailServerProfile,
|
||||
fetchMailSettingsDelta,
|
||||
getMailProfilePolicy,
|
||||
listImapFolders,
|
||||
listMailProfileImapFolders,
|
||||
mailProfilePatternKeys,
|
||||
mailProfilePolicyLimitKeys,
|
||||
listAvailableMailCredentials,
|
||||
@@ -102,6 +104,7 @@ type ProfileDraft = {
|
||||
imapUsername: string;
|
||||
imapPassword: string;
|
||||
imapSentFolder: string;
|
||||
imapFolderMappings: MailImapFolderMappings;
|
||||
imapTimeout: string;
|
||||
};
|
||||
|
||||
@@ -740,11 +743,11 @@ export function MailProfileScopeManager({
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={editing !== null}
|
||||
title={profileDialogTitle(editing, editingTarget)}
|
||||
onClose={() => !busy && closeProfileEditor()}
|
||||
className="admin-dialog admin-dialog-wide mail-profile-dialog"
|
||||
className="mail-profile-dialog"
|
||||
closeDisabled={busy}
|
||||
footer={editing === "new"
|
||||
? <>
|
||||
@@ -1020,13 +1023,13 @@ export function MailProfilePolicyEditor({
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<section className="mail-policy-section policy-section">
|
||||
<div className="subsection-heading split">
|
||||
<ActionToolbar surface="section-header" className="subsection-heading split">
|
||||
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
|
||||
<div className="button-row compact-actions">
|
||||
{lowerLevelLimitToggle("allowed_profile_ids")}
|
||||
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>i18n:govoplan-mail.clear_allow_list.f69c8c67</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<p className="muted small-note">{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 })}</p>
|
||||
<AdminSelectionList
|
||||
options={candidateProfiles.map((profile) => ({
|
||||
@@ -1163,7 +1166,8 @@ function ProfileForm({
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
|
||||
const [imapFolderResult, setImapFolderResult] = useState<MailImapFolderListResponse | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||
|
||||
const disabled = busy || !canWrite;
|
||||
const credentialDisabled = disabled || !canManageCredentials;
|
||||
@@ -1256,6 +1260,7 @@ function ProfileForm({
|
||||
useEffect(() => {
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setImapFolderResult(null);
|
||||
setMailActionState(null);
|
||||
}, [editing, editTarget]);
|
||||
|
||||
@@ -1276,6 +1281,7 @@ function ProfileForm({
|
||||
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
|
||||
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
|
||||
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
|
||||
imapFolderMappings: patch.folder_mappings !== undefined ? normalizeMailImapFolderMappings(patch.folder_mappings) : draft.imapFolderMappings,
|
||||
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
|
||||
});
|
||||
}
|
||||
@@ -1325,6 +1331,21 @@ function ProfileForm({
|
||||
}
|
||||
}
|
||||
|
||||
async function runImapFolderLookup() {
|
||||
if (!draftHasImap) return;
|
||||
setMailActionState("folders");
|
||||
setImapFolderResult(null);
|
||||
try {
|
||||
setImapFolderResult(useSavedImapTest && existingProfile ?
|
||||
await listMailProfileImapFolders(settings, existingProfile.id, selectedServerId, selectedCredentialId) :
|
||||
await listImapFolders(settings, rawImapPayload(draft, false)));
|
||||
} catch (err) {
|
||||
setImapFolderResult({ ok: false, protocol: "imap", message: errorMessage(err), folders: [] });
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mail-profile-form">
|
||||
{!canManageCredentials && (
|
||||
@@ -1343,7 +1364,7 @@ function ProfileForm({
|
||||
documentation={MAIL_PROFILE_DOCUMENTATION} />
|
||||
}
|
||||
{showProfileFields &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-mail.name.709a2322" documentation={MAIL_PROFILE_DOCUMENTATION}><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
|
||||
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
|
||||
@@ -1355,7 +1376,7 @@ function ProfileForm({
|
||||
onChange={(checked) => setDraft({ ...draft, inheritToLowerScopes: checked })}
|
||||
label="Visible to lower scopes" />
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{editTarget.kind === "profile" && existingProfile &&
|
||||
@@ -1363,7 +1384,7 @@ function ProfileForm({
|
||||
}
|
||||
|
||||
{editTarget.kind === "server" &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="Server name" documentation={MAIL_PROFILE_DOCUMENTATION}>
|
||||
<input value={draft.serverName} disabled={disabled} onChange={(event) => setDraft({ ...draft, serverName: event.target.value })} />
|
||||
</FormField>
|
||||
@@ -1387,11 +1408,11 @@ function ProfileForm({
|
||||
onChange={(checked) => setDraft({ ...draft, serverInheritToLowerScopes: checked })}
|
||||
label="Visible to lower scopes" />
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{editTarget.kind === "credentials" &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
{creatingCredential &&
|
||||
<FormField label="Reusable credential" documentation={MAIL_PROFILE_WORKFLOW_DOCUMENTATION}>
|
||||
<select value={reuseCredentialId} disabled={credentialDisabled} onChange={(event) => setReuseCredentialId(event.target.value)}>
|
||||
@@ -1455,7 +1476,7 @@ function ProfileForm({
|
||||
The selected credential remains independently managed and will be linked to this server.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{policyMessages.length > 0 && (
|
||||
@@ -1472,7 +1493,7 @@ function ProfileForm({
|
||||
{showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) &&
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
|
||||
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
|
||||
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout }}
|
||||
smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }}
|
||||
imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }}
|
||||
onSmtpChange={patchSmtpSettings}
|
||||
@@ -1501,8 +1522,10 @@ function ProfileForm({
|
||||
busyAction={mailActionState}
|
||||
onTestSmtp={() => void runSmtpTest()}
|
||||
onTestImap={() => void runImapTest()}
|
||||
onLookupImapFolders={() => void runImapFolderLookup()}
|
||||
smtpTestResult={smtpTestResult}
|
||||
imapTestResult={imapTestResult}
|
||||
imapFolderLookupResult={imapFolderResult}
|
||||
initialSection={initialSection}
|
||||
visibleSections={visibleSections}
|
||||
mode={settingsPanelMode} />
|
||||
@@ -1645,6 +1668,7 @@ function emptyProfileDraft(): ProfileDraft {
|
||||
imapUsername: "",
|
||||
imapPassword: "",
|
||||
imapSentFolder: "auto",
|
||||
imapFolderMappings: {},
|
||||
imapTimeout: "30"
|
||||
};
|
||||
}
|
||||
@@ -1696,7 +1720,11 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
|
||||
? targetUsername
|
||||
: stringValue(profile.credentials?.imap?.username ?? profile.imap?.username),
|
||||
imapPassword: "",
|
||||
imapSentFolder: stringValue("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto"),
|
||||
imapSentFolder: stringValue(imapConfig?.folder_mappings?.sent || ("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto")),
|
||||
imapFolderMappings: normalizeMailImapFolderMappings({
|
||||
...(imapConfig?.folder_mappings ?? {}),
|
||||
...(!imapConfig?.folder_mappings?.sent && imapConfig?.sent_folder && imapConfig.sent_folder !== "auto" ? { sent: imapConfig.sent_folder } : {})
|
||||
}),
|
||||
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
|
||||
};
|
||||
}
|
||||
@@ -1873,7 +1901,7 @@ function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
||||
|
||||
function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
|
||||
return mailImapSettingsPayload<MailSecurity>(
|
||||
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout },
|
||||
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback } from "react";
|
||||
import { ExternalLink, FilePenLine, Mail, Pencil } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
quickAccessLaunchState,
|
||||
useDashboardWidgetData,
|
||||
type QuickAccessToolRenderContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
listMailServerProfiles,
|
||||
type MailMailboxMessageSummary
|
||||
} from "../../api/mail";
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxMessageLaunchPath
|
||||
} from "./mailboxLaunch";
|
||||
|
||||
|
||||
type MailQuickAccessData = {
|
||||
profileName?: string;
|
||||
profileId?: string;
|
||||
draftsFolder?: string | null;
|
||||
messages: MailMailboxMessageSummary[];
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
type Props = Pick<
|
||||
QuickAccessToolRenderContext,
|
||||
"settings" | "launchContext" | "close"
|
||||
>;
|
||||
|
||||
export default function MailQuickAccess({ settings, launchContext, close }: Props) {
|
||||
const load = useCallback(async (): Promise<MailQuickAccessData> => {
|
||||
const profiles = await listMailServerProfiles(settings);
|
||||
const profile = profiles.find((item) => item.is_active && item.imap);
|
||||
if (!profile) return { messages: [], available: false };
|
||||
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false);
|
||||
return {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
draftsFolder: profile.imap?.folder_mappings?.drafts
|
||||
|| response.folders.detected_folder_mappings?.drafts
|
||||
|| null,
|
||||
messages: response.messages.messages ?? [],
|
||||
available: true
|
||||
};
|
||||
}, [settings]);
|
||||
const { data, loading, error } = useDashboardWidgetData(load, 0);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_messages.4294022c">
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<DashboardWidgetList
|
||||
emptyText={data?.available ? "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d" : "i18n:govoplan-mail.select_an_imap_profile.5445648c"}
|
||||
items={(data?.messages ?? []).map((message) => ({
|
||||
id: `${message.folder}:${message.uid}`,
|
||||
title: message.subject || "i18n:govoplan-mail.no_subject.49b20da0",
|
||||
detail: message.from_header || data?.profileName,
|
||||
meta: formatMessageDate(message.date),
|
||||
leading: <Mail size={17} aria-hidden="true" />,
|
||||
to: mailboxMessageLaunchPath(data!.profileId!, message),
|
||||
state: quickAccessLaunchState(launchContext),
|
||||
onClick: close
|
||||
}))}
|
||||
/>
|
||||
<div className="dashboard-contribution-footer">
|
||||
<a className="btn btn-secondary" href="mailto:" onClick={close}>
|
||||
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.compose
|
||||
</a>
|
||||
{data?.profileId && data.draftsFolder ? (
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={mailboxDraftsLaunchPath(data.profileId, data.draftsFolder)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<FilePenLine size={15} aria-hidden="true" /> i18n:govoplan-mail.drafts.22a31d86
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to="/mail"
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail
|
||||
</Link>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function formatMessageDate(value?: string | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(parsed);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
import { Activity, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import { ToolbarGroup, ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
CountBadge,
|
||||
DataGridPaginationBar,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
|
||||
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
|
||||
|
||||
const MAILBOX_DOCUMENTATION = {
|
||||
topicId: "mail.workflow.read-mailbox",
|
||||
@@ -36,6 +40,7 @@ const MAILBOX_DOCUMENTATION = {
|
||||
|
||||
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||
@@ -44,6 +49,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
||||
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
|
||||
const [messagePage, setMessagePage] = useState(1);
|
||||
const [messagePageSize, setMessagePageSize] = useState(10);
|
||||
const [messageQuery, setMessageQuery] = useState("");
|
||||
@@ -64,6 +70,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const messageDetailRequestRef = useRef(0);
|
||||
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
|
||||
const skipNextMessageLoadRef = useRef(false);
|
||||
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
|
||||
|
||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||
@@ -76,6 +83,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
||||
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
|
||||
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
|
||||
const syncState = mailboxSyncState(messageProvenance);
|
||||
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
||||
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
|
||||
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
|
||||
@@ -95,6 +103,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
: "";
|
||||
|
||||
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => {
|
||||
const request = parseMailboxLaunch(location.search);
|
||||
launchRequestRef.current = request;
|
||||
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
|
||||
const usable = profiles.filter((profile) => profile.is_active && profile.imap);
|
||||
const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId)
|
||||
? request.profileId
|
||||
: selectedProfileId || usable[0]?.id || "";
|
||||
if (!targetProfileId) return;
|
||||
if (targetProfileId !== selectedProfileId) {
|
||||
selectProfile(targetProfileId);
|
||||
return;
|
||||
}
|
||||
void loadMailboxBootstrap(targetProfileId);
|
||||
}, [location.search]);
|
||||
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
||||
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
|
||||
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
|
||||
@@ -107,6 +130,19 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
|
||||
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
const request = launchRequestRef.current;
|
||||
if (!request || !foldersReady || loadingMessages) return;
|
||||
const targetProfileMatches = !request.profileId || request.profileId === selectedProfileId;
|
||||
const targetFolder = mailboxLaunchFolder(request, selectedProfile);
|
||||
if (!targetProfileMatches || (targetFolder && targetFolder !== selectedFolder)) return;
|
||||
const target = request.messageUid
|
||||
? messages.find((message) => message.uid === request.messageUid)
|
||||
: null;
|
||||
launchRequestRef.current = null;
|
||||
if (target) void openMessage(target);
|
||||
}, [foldersReady, loadingMessages, messages, selectedFolder, selectedProfile, selectedProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
@@ -154,13 +190,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
try {
|
||||
const loaded = await listMailServerProfiles(settings);
|
||||
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
||||
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
|
||||
setProfiles(loaded);
|
||||
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
||||
setSelectedProfileId((current) =>
|
||||
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId)
|
||||
? requestedProfileId
|
||||
: current && usable.some((profile) => profile.id === current)
|
||||
? current
|
||||
: usable[0]?.id ?? ""
|
||||
);
|
||||
if (usable.length === 0) {
|
||||
setFolders([]);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -176,6 +220,14 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
|
||||
if (!profileId) return;
|
||||
const profileInbox = profiles.find((profile) => profile.id === profileId)?.imap?.folder_mappings?.inbox || "";
|
||||
const requestedLaunch = launchRequestRef.current;
|
||||
const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId)
|
||||
? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null)
|
||||
: null;
|
||||
const requestedFolder = requestedLaunchFolder || (foldersLoadedForProfile === profileId && selectedFolder
|
||||
? selectedFolder
|
||||
: profileInbox || "INBOX");
|
||||
const folderRequestId = ++folderRequestRef.current;
|
||||
const messageRequestId = ++messageListRequestRef.current;
|
||||
messageDetailRequestRef.current += 1;
|
||||
@@ -183,6 +235,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setLoadingMessages(true);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -193,7 +246,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setDetailError("");
|
||||
setError("");
|
||||
try {
|
||||
const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
|
||||
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh);
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
|
||||
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
|
||||
@@ -201,7 +254,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const total = response.messages.total_count ?? loadedMessages.length;
|
||||
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
|
||||
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
|
||||
nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||
const detectedInbox = response.folders.detected_folder_mappings?.inbox;
|
||||
nextFolder = detectedInbox && loadedFolders.some((folder) => folder.name === detectedInbox)
|
||||
? detectedInbox
|
||||
: loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||
}
|
||||
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
|
||||
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
|
||||
@@ -216,6 +272,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile(profileId);
|
||||
setMessages(loadedMessages);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response.messages));
|
||||
} catch (err) {
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
const message = errorText(err);
|
||||
@@ -227,6 +284,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
@@ -244,6 +302,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
|
||||
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
|
||||
setLoadingMessages(true);
|
||||
setMessageProvenance(null);
|
||||
setMessageError("");
|
||||
setDetailError("");
|
||||
setError("");
|
||||
@@ -256,6 +315,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
|
||||
setMessages(loaded);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response));
|
||||
setFolderMessageCount(folder, total);
|
||||
const rememberedKey = selectedMessageKeyRef.current;
|
||||
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
|
||||
@@ -270,6 +330,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setError(message);
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -316,6 +377,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -395,7 +457,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<span className="mailbox-tree-node-label">
|
||||
<span className="mailbox-tree-node-main">
|
||||
<span>{node.label}</span>
|
||||
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
|
||||
{showCount && <CountBadge tone="neutral" size="compact" className="mailbox-folder-count">{node.messageCount}</CountBadge>}
|
||||
</span>
|
||||
{flagText && <small>{flagText}</small>}
|
||||
</span>);
|
||||
@@ -407,7 +469,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
||||
<div className="file-list-sticky">
|
||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||
<label className="mailbox-profile-field">
|
||||
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
|
||||
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||
@@ -415,8 +477,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
|
||||
<div className="mailbox-toolbar-actions">
|
||||
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</ToolbarGroup>
|
||||
<ToolbarGroup align="end" className="mailbox-toolbar-actions">
|
||||
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
|
||||
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
|
||||
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
|
||||
@@ -435,8 +497,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
i18n:govoplan-mail.messages.f1702b46
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</ActionToolbar>
|
||||
|
||||
{noImapProfiles &&
|
||||
<ActionBlockerHint
|
||||
@@ -468,6 +530,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<span>{messageCountLabel}</span>
|
||||
<span>{selectedFolder}</span>
|
||||
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
|
||||
{syncState &&
|
||||
<span className={`mailbox-sync-provenance is-${syncState}`} aria-live="polite">
|
||||
<Database size={13} aria-hidden="true" />
|
||||
{syncState === "refreshing" ? "i18n:govoplan-mail.cached_index_refreshing.75f18a6c" : syncState === "cached" ? "i18n:govoplan-mail.cached_mailbox_index.16fe75d1" : "i18n:govoplan-mail.live_provider_response.39c46538"}
|
||||
{messageProvenance?.indexedAt && <span> · {formatDateTime(messageProvenance.indexedAt, { fallback: "-" })}</span>}
|
||||
</span>
|
||||
}
|
||||
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
|
||||
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
||||
</div>
|
||||
@@ -486,10 +555,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||
const selected = key === selectedMessageKey;
|
||||
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||
const read = isMailboxMessageRead(message.flags);
|
||||
return (
|
||||
<div
|
||||
key={message.uid}
|
||||
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||
className={`file-list-row file-row mailbox-message-row ${read ? "is-read" : "is-unread"} ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||
role="row"
|
||||
tabIndex={0}
|
||||
onClick={() => void openMessage(message)}
|
||||
@@ -502,7 +572,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
<div className="file-list-name-cell">
|
||||
<div className="file-list-name">
|
||||
<Mail className="file-row-icon" size={20} aria-hidden="true" />
|
||||
{read ? <MailOpen className="file-row-icon" size={20} aria-hidden="true" /> : <Mail className="file-row-icon" size={20} aria-hidden="true" />}
|
||||
<span>
|
||||
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
||||
<small>{message.from_header || "-"}</small>
|
||||
@@ -511,6 +581,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
</div>
|
||||
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
||||
<span className="file-row-tail mailbox-message-tail">
|
||||
<span className="mailbox-read-state" title={read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}>
|
||||
<span className="visually-hidden">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||
<span aria-hidden="true">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||
</span>
|
||||
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
|
||||
<span>{formatBytes(message.size_bytes)}</span>
|
||||
</span>
|
||||
@@ -576,6 +650,14 @@ function mailboxCursorKey(profileId: string, folder: string, pageSize: number):
|
||||
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
|
||||
}
|
||||
|
||||
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
|
||||
return {
|
||||
fromCache: response.from_cache === true,
|
||||
refreshing: response.refreshing === true,
|
||||
indexedAt: response.indexed_at ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return messages;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type MailboxSyncProvenance = {
|
||||
fromCache: boolean;
|
||||
refreshing: boolean;
|
||||
indexedAt: string | null;
|
||||
};
|
||||
|
||||
export type MailboxSyncState = "live" | "cached" | "refreshing";
|
||||
|
||||
export function isMailboxMessageRead(flags: readonly string[] | null | undefined): boolean {
|
||||
return (flags ?? []).some((flag) => flag.trim().replace(/^\\+/, "").toLocaleLowerCase() === "seen");
|
||||
}
|
||||
|
||||
export function mailboxSyncState(provenance: MailboxSyncProvenance | null): MailboxSyncState | null {
|
||||
if (!provenance) return null;
|
||||
if (provenance.refreshing) return "refreshing";
|
||||
return provenance.fromCache ? "cached" : "live";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
type MessageReference = { folder: string; uid: string };
|
||||
type ProfileFolderMapping = {
|
||||
imap?: { folder_mappings?: { drafts?: string | null } | null } | null;
|
||||
};
|
||||
|
||||
export type MailboxLaunch = {
|
||||
profileId: string | null;
|
||||
folder: string | null;
|
||||
folderRole: "drafts" | null;
|
||||
messageUid: string | null;
|
||||
};
|
||||
|
||||
export function parseMailboxLaunch(search: string): MailboxLaunch {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
profileId: boundedValue(params.get("profile")),
|
||||
folder: boundedValue(params.get("folder")),
|
||||
folderRole: params.get("folderRole") === "drafts" ? "drafts" : null,
|
||||
messageUid: boundedValue(params.get("message"))
|
||||
};
|
||||
}
|
||||
|
||||
export function mailboxMessageLaunchPath(
|
||||
profileId: string,
|
||||
message: MessageReference
|
||||
): string {
|
||||
return mailPath({
|
||||
profile: profileId,
|
||||
folder: message.folder,
|
||||
message: message.uid
|
||||
});
|
||||
}
|
||||
|
||||
export function mailboxDraftsLaunchPath(profileId: string, folder?: string | null): string {
|
||||
return mailPath(folder
|
||||
? { profile: profileId, folder }
|
||||
: { profile: profileId, folderRole: "drafts" });
|
||||
}
|
||||
|
||||
export function mailboxLaunchFolder(
|
||||
launch: MailboxLaunch,
|
||||
profile: ProfileFolderMapping | null
|
||||
): string | null {
|
||||
if (launch.folder) return launch.folder;
|
||||
if (launch.folderRole === "drafts") {
|
||||
return boundedValue(profile?.imap?.folder_mappings?.drafts ?? null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mailPath(values: Record<string, string>): string {
|
||||
const params = new URLSearchParams(values);
|
||||
return `/mail?${params.toString()}`;
|
||||
}
|
||||
|
||||
function boundedValue(value: string | null): string | null {
|
||||
const clean = value?.trim() ?? "";
|
||||
return clean && clean.length <= 500 ? clean : null;
|
||||
}
|
||||
@@ -95,6 +95,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Compose",
|
||||
"i18n:govoplan-mail.open_mail": "Open Mail",
|
||||
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||
@@ -292,6 +295,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Verfassen",
|
||||
"i18n:govoplan-mail.open_mail": "Mail öffnen",
|
||||
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||
|
||||
+14
-3
@@ -1,9 +1,10 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule, QuickAccessToolsUiCapability } from "@govoplan/core-webui";
|
||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import MailQuickAccess from "./features/mail/MailQuickAccess";
|
||||
import "./styles/mail-profiles.css";
|
||||
|
||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||
@@ -14,6 +15,14 @@ const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
const mailQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
{
|
||||
id: "mail.messages",
|
||||
render: (context) => createElement(MailQuickAccess, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const mailModule: PlatformWebModule = {
|
||||
id: "mail",
|
||||
@@ -27,7 +36,8 @@ export const mailModule: PlatformWebModule = {
|
||||
{ id: "mail.admin.tenant-servers", moduleId: "mail", kind: "section", label: "Tenant mail servers", order: 60 },
|
||||
{ id: "mail.admin.group-servers", moduleId: "mail", kind: "section", label: "Group mail servers", order: 20 },
|
||||
{ id: "mail.admin.user-servers", moduleId: "mail", kind: "section", label: "User mail servers", order: 20 },
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 }
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 },
|
||||
{ id: "mail.quick_access.messages", moduleId: "mail", kind: "quick_access", label: "Mail Quick Access", order: 80 }
|
||||
],
|
||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
routes: [
|
||||
@@ -36,7 +46,8 @@ export const mailModule: PlatformWebModule = {
|
||||
|
||||
uiCapabilities: {
|
||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
||||
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
|
||||
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors,
|
||||
"quickAccess.tools": mailQuickAccessTools
|
||||
},
|
||||
runtimeUiCapabilities: {
|
||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||
|
||||
@@ -186,14 +186,6 @@
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mailbox-toolbar,
|
||||
.mailbox-message-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mailbox-toolbar label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
@@ -385,7 +377,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1280px) {
|
||||
.mailbox-layout {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
@@ -396,8 +388,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mailbox-toolbar,
|
||||
.mailbox-message-toolbar {
|
||||
.mailbox-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -458,13 +449,6 @@
|
||||
|
||||
.mailbox-folder-count {
|
||||
flex: 0 0 auto;
|
||||
min-width: 20px;
|
||||
padding: 1px 6px;
|
||||
border: var(--border-line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mailbox-toolbar.file-manager-toolbar {
|
||||
@@ -576,6 +560,14 @@
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-read .file-list-name strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-unread .file-list-name strong {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.mailbox-message-row .file-list-name strong {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -599,6 +591,27 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mailbox-read-state,
|
||||
.mailbox-sync-provenance {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-unread .mailbox-read-state {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.mailbox-sync-provenance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mailbox-sync-provenance.is-refreshing {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.mailbox-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -614,7 +627,7 @@
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
@media (max-width: 1280px) {
|
||||
.mailbox-shell.file-manager-shell {
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
import { isMailboxMessageRead, mailboxSyncState } from "../src/features/mail/mailboxDisplay";
|
||||
|
||||
assert(isMailboxMessageRead(["\\Seen"]), "standard IMAP Seen flag marks a message as read");
|
||||
assert(isMailboxMessageRead(["answered", "SEEN"]), "flag matching is case-insensitive and accepts normalized flags");
|
||||
assert(!isMailboxMessageRead(["\\Answered", "\\Flagged"]), "messages without Seen remain unread");
|
||||
assert(!isMailboxMessageRead(undefined), "missing flags fail safely to unread");
|
||||
|
||||
assertEqual(mailboxSyncState({ fromCache: false, refreshing: false, indexedAt: null }), "live");
|
||||
assertEqual(mailboxSyncState({ fromCache: true, refreshing: false, indexedAt: "2026-08-19T09:00:00Z" }), "cached");
|
||||
assertEqual(mailboxSyncState({ fromCache: true, refreshing: true, indexedAt: "2026-08-19T09:00:00Z" }), "refreshing");
|
||||
assertEqual(mailboxSyncState(null), null);
|
||||
@@ -0,0 +1,46 @@
|
||||
function assertEqual(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxLaunchFolder,
|
||||
mailboxMessageLaunchPath,
|
||||
parseMailboxLaunch
|
||||
} from "../src/features/mail/mailboxLaunch";
|
||||
|
||||
assertDeepEqual(
|
||||
parseMailboxLaunch("?profile=profile-1&folder=INBOX%2FTeam&message=42"),
|
||||
{
|
||||
profileId: "profile-1",
|
||||
folder: "INBOX/Team",
|
||||
folderRole: null,
|
||||
messageUid: "42"
|
||||
}
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
mailboxMessageLaunchPath("profile 1", { folder: "INBOX/Team", uid: "42" }),
|
||||
"/mail?profile=profile+1&folder=INBOX%2FTeam&message=42"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1"),
|
||||
"/mail?profile=profile-1&folderRole=drafts"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1", "Entwürfe"),
|
||||
"/mail?profile=profile-1&folder=Entw%C3%BCrfe"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxLaunchFolder(parseMailboxLaunch("?folderRole=drafts"), {
|
||||
imap: { folder_mappings: { drafts: "Entwürfe" } }
|
||||
}),
|
||||
"Entwürfe"
|
||||
);
|
||||
|
||||
console.log("mailbox launch tests passed");
|
||||
@@ -17,10 +17,14 @@
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/mailbox-display.test.ts",
|
||||
"tests/mailbox-folders.test.ts",
|
||||
"tests/mailbox-launch.test.ts",
|
||||
"tests/mail-profile-editor-model.test.ts",
|
||||
"tests/mail-policy-validation.test.ts",
|
||||
"src/features/mail/mailboxDisplay.ts",
|
||||
"src/features/mail/mailboxFolders.ts",
|
||||
"src/features/mail/mailboxLaunch.ts",
|
||||
"src/features/mail/mailProfileEditorModel.ts",
|
||||
"src/features/mail/mailPolicyValidation.ts"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user