campaign sending prototype

This commit is contained in:
2026-06-24 16:20:37 +02:00
parent 44516a03aa
commit ce30b4d054
17 changed files with 1233 additions and 14 deletions

View File

@@ -21,6 +21,7 @@ Canonical policy documents live in `docs/`:
- [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md) - [RBAC_MANIFEST.md](docs/RBAC_MANIFEST.md)
- [SYSTEM_GOVERNANCE_MANIFEST.md](docs/SYSTEM_GOVERNANCE_MANIFEST.md) - [SYSTEM_GOVERNANCE_MANIFEST.md](docs/SYSTEM_GOVERNANCE_MANIFEST.md)
- [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md)
Modules may define module-specific permissions and policy behavior, but the platform-level permission model and governance hierarchy belong here. Modules may define module-specific permissions and policy behavior, but the platform-level permission model and governance hierarchy belong here.
@@ -33,14 +34,20 @@ cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt ./.venv/bin/python -m pip install -r requirements-dev.txt
``` ```
Run the platform server from core. The default config loads the access/core module only; product configs can enable installed modules. Run the platform server from core through the module-aware development runner. The default config loads the access/core module only; product configs can enable installed modules.
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
GOVOPLAN_SERVER_CONFIG=app.govoplan_config:get_server_config \ ./.venv/bin/python -m govoplan_core.devserver \
./.venv/bin/python -m uvicorn govoplan_core.server.app:app --reload --host 127.0.0.1 --port 8000 --config app.govoplan_config:get_server_config \
--host 127.0.0.1 \
--port 8000
``` ```
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
`requirements-dev.txt` currently links the local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. Production deployments should use tagged git dependencies or published packages. `requirements-dev.txt` currently links the local `govoplan-files`, `govoplan-mail`, and `govoplan-campaign` checkouts for development. Production deployments should use tagged git dependencies or published packages.
## WebUI development ## WebUI development
@@ -65,4 +72,4 @@ Backend modules register through the `govoplan.modules` entry point and return a
- nav metadata and frontend package metadata - nav metadata and frontend package metadata
- resource ACL providers and tenant summary/delete-veto providers - resource ACL providers and tenant summary/delete-veto providers
WebUI modules export a `PlatformWebModule` with nav items and route contributions. Core renders those routes with `settings` and `auth` context. WebUI modules export a `PlatformWebModule` with nav items and route contributions. Core renders those routes with `settings` and `auth` context. Frontend nav icons must be supplied as core-resolved `iconName` strings, not imported icon components. See [MODULE_ARCHITECTURE.md](docs/MODULE_ARCHITECTURE.md) for the full module-building contract.

176
docs/MODULE_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,176 @@
# GovOPlaN Module Architecture
GovOPlaN is structured as a core platform runner plus installable feature modules. Core owns the runtime shell and cross-cutting primitives. Modules own feature behavior and contribute backend routes, database metadata, permissions, WebUI routes, and navigation metadata.
## Core Responsibilities
Core owns:
- the server entry point and platform configuration
- module discovery, registry validation, route aggregation, and platform metadata APIs
- database engine/session primitives and migration orchestration
- auth, tenants, RBAC, governance, audit, CSRF/API helpers, and secret helpers
- shared WebUI shell components such as `AppShell`, `IconRail`, `DataGrid`, `ExplorerTree`, `MessageDisplayPanel`, dialogs, loading frames, access boundaries, and form primitives
- centralized mapping from serializable icon names to renderable frontend icons
Core must not import module feature pages or module business logic directly. It should interact with modules through manifests, entry points, metadata, and route contributions.
## Module Responsibilities
A module owns one bounded feature area. A module can include both backend and WebUI code in the same repository so feature behavior and frontend integration evolve together.
A module owns:
- backend routers and feature services
- SQLAlchemy models for module-owned tables
- module migrations and migration metadata
- module permissions and role templates
- module-specific schemas, policies, and domain rules
- WebUI pages, feature-specific components, API clients, route contributions, and navigation metadata
A module should not own generic platform UI. If a component is useful outside one module, move it to `@govoplan/core-webui` and parameterize it there before reusing it.
## Backend Contract
Backend modules register through the `govoplan.modules` entry point and expose a `ModuleManifest`.
Example:
```toml
[project.entry-points."govoplan.modules"]
files = "govoplan_files.backend.manifest:get_manifest"
```
The manifest should declare:
- `id`, `name`, `version`
- required `dependencies` and `optional_dependencies`
- permissions and role templates
- router factory
- migration metadata and script location
- frontend package metadata
- navigation metadata using serializable icon names
Backend nav metadata must use icon-name strings, not frontend components:
```python
NavItem(
path="/files",
label="Files",
icon="folder",
required_any=("files:file:read",),
order=40,
)
```
## Database And Migrations
Core owns the database/session lifecycle. Modules access the database through core session dependencies and register their models/migrations through their manifest.
Rules:
- Do not create independent database engines in modules.
- Use core session dependencies, base metadata, and migration orchestration.
- Keep module-owned tables and migrations in the module repository.
- Keep cross-module foreign-key assumptions explicit and conservative.
- Register module metadata in `MigrationSpec` so core can discover it.
## WebUI Contract
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
Example:
```ts
export const filesModule: PlatformWebModule = {
id: "files",
label: "Files",
version: "1.0.0",
dependencies: ["access"],
navItems: [
{ to: "/files", label: "Files", iconName: "folder", anyOf: ["files:file:read"], order: 40 }
],
routes: [
{ path: "/files", anyOf: ["files:file:read"], order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }
]
};
```
WebUI modules receive only the core route context:
- `settings`
- `auth`
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
## Icon Rules
Icons are resolved centrally by core.
Modules must provide icon names with `iconName` in frontend nav contributions and `icon` in backend manifest metadata. Modules must not import Lucide icons for navigation metadata.
Current core icon names include:
- `activity`
- `campaign`
- `dashboard`
- `file`
- `files`
- `folder`
- `form`
- `mail`
- `reports`
- `users`
If a module needs a new navigation icon, add the name-to-component mapping in core first, then use the name in backend and frontend metadata.
## Shared Component Rules
Use this rule of thumb:
- If it is platform-level or likely reusable by more than one module, define it in `@govoplan/core-webui` with parameters.
- If it is feature-specific and only meaningful inside one bounded module, keep it in that module.
- Modules must not import components from another feature module.
- If one module needs a component currently owned by another module, promote a generic version into core and replace the old usage with the core component.
Examples:
- `ExplorerTree` is core because files, mailboxes, and future modules can all render hierarchical navigation.
- `MessageDisplayPanel` is core because mail, campaign sending, and later audit/review surfaces can display message-like content.
- `MailProfileManagement` remains in the mail module because it is specific to mail transport policies and profiles.
## Cross-Module Integration
A module can declare required dependencies and optional dependencies. Optional behavior should be enabled by module presence and permissions, not by importing another module's WebUI internals.
Rules:
- Use core module metadata to check whether another module is installed.
- Use backend APIs/events/service contracts for runtime cooperation.
- Keep UI integration declarative where possible: nav items, route contributions, context actions, and explicit extension points.
- Avoid direct imports from one feature module into another feature module unless the imported package is a published API contract designed for that purpose. UI components should be promoted to core instead.
## Build And Verification
Backend verification from core:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m compileall src/govoplan_core ../govoplan-files/src/govoplan_files ../govoplan-mail/src/govoplan_mail ../govoplan-campaign/src/govoplan_campaign
```
Core WebUI host verification:
```bash
cd /mnt/DATA/git/govoplan-core/webui
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
```
Wrapper WebUI verification, while the legacy wrapper still exists:
```bash
cd /mnt/DATA/git/multi-seal-mail-webui
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
```
Clean generated `dist`, `.vite`, and source-tree `__pycache__` artifacts after verification unless they are intentionally part of a release artifact.

View File

@@ -27,6 +27,9 @@ where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
govoplan_core = ["py.typed"] govoplan_core = ["py.typed"]
[project.scripts]
govoplan-devserver = "govoplan_core.devserver:main"
[project.entry-points."govoplan.modules"] [project.entry-points."govoplan.modules"]
access = "govoplan_core.access.manifest:get_manifest" access = "govoplan_core.access.manifest:get_manifest"

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
import argparse
import importlib.util
import inspect
import os
import sys
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.config import GovoplanServerConfig, import_object, load_server_config
from govoplan_core.server.registry import build_platform_registry
DEFAULT_APP = "govoplan_core.server.app:app"
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
def _config_module_runtime_root(config_path: str | None) -> Path | None:
object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG")
if not object_path:
return None
module_name = object_path.split(":", 1)[0]
try:
spec = importlib.util.find_spec(module_name)
except (ImportError, AttributeError, ValueError):
return None
if spec is None or not spec.origin:
return None
source = Path(spec.origin).resolve()
if source.name == "__init__.py":
package_dir = source.parent
else:
package_dir = source.parent
if package_dir.name == "app":
return package_dir.parent
return None
def apply_runtime_defaults(config_path: str | None) -> Path | None:
runtime_root = _config_module_runtime_root(config_path)
if runtime_root is None:
return None
defaults = {
"DATABASE_URL": f"sqlite:///{runtime_root / 'multimailer-dev.db'}",
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
}
for key, value in defaults.items():
os.environ.setdefault(key, value)
return runtime_root
def _source_root_for_path(path: str | os.PathLike[str]) -> Path | None:
source_path = Path(path).resolve()
directory = source_path if source_path.is_dir() else source_path.parent
if not directory.exists():
return None
for candidate in (directory, *directory.parents):
if candidate.name == "src":
return candidate
return directory
def _source_roots_for_object(value: Any) -> tuple[Path, ...]:
candidates: list[Any] = [value]
if not inspect.isclass(value) and not inspect.ismodule(value):
candidates.append(type(value))
roots: list[Path] = []
for candidate in candidates:
try:
source = inspect.getsourcefile(candidate) or inspect.getfile(candidate)
except (TypeError, OSError):
continue
root = _source_root_for_path(source)
if root is not None:
roots.append(root)
return tuple(roots)
def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]:
roots: list[Path] = []
if manifest.route_factory is not None:
roots.extend(_source_roots_for_object(manifest.route_factory))
if manifest.migration_spec is not None and manifest.migration_spec.script_location:
root = _source_root_for_path(manifest.migration_spec.script_location)
if root is not None:
roots.append(root)
for provider in manifest.resource_acl_providers:
roots.extend(_source_roots_for_object(provider))
for provider in manifest.tenant_summary_providers:
roots.extend(_source_roots_for_object(provider))
for providers in manifest.delete_veto_providers.values():
for provider in providers:
roots.extend(_source_roots_for_object(provider))
return tuple(roots)
def _entry_point_source_roots(enabled_module_ids: set[str]) -> tuple[Path, ...]:
roots: list[Path] = []
for entry_point in iter_module_entry_points():
loaded = entry_point.load()
manifest = loaded() if callable(loaded) else loaded
if not isinstance(manifest, ModuleManifest) or manifest.id not in enabled_module_ids:
continue
roots.extend(_source_roots_for_object(loaded))
return tuple(roots)
def _config_source_roots(config_path: str | None) -> tuple[Path, ...]:
object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG") or DEFAULT_CONFIG
try:
loaded = import_object(object_path)
except ModuleNotFoundError:
if config_path or os.getenv("GOVOPLAN_SERVER_CONFIG"):
raise
loaded = import_object(DEFAULT_CONFIG)
return _source_roots_for_object(loaded)
def unique_existing_dirs(paths: Iterable[Path | str]) -> list[str]:
seen: set[Path] = set()
result: list[str] = []
for item in paths:
path = Path(item).resolve()
if path in seen or not path.is_dir():
continue
seen.add(path)
result.append(str(path))
return result
def build_reload_dirs(
config: GovoplanServerConfig,
*,
config_path: str | None = None,
registry: PlatformRegistry | None = None,
extra_dirs: Sequence[str] = (),
) -> list[str]:
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
manifests = active_registry.manifests()
enabled_module_ids = {manifest.id for manifest in manifests}
roots: list[Path | str] = []
roots.extend(_config_source_roots(config_path))
for factory in config.manifest_factories:
roots.extend(_source_roots_for_object(factory))
roots.extend(_entry_point_source_roots(enabled_module_ids))
for manifest in manifests:
roots.extend(_manifest_source_roots(manifest))
roots.extend(extra_dirs)
return unique_existing_dirs(roots)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the GovOPlaN development server with module-aware reload directories.")
parser.add_argument("--config", help="GovoplanServerConfig object path, e.g. app.govoplan_config:get_server_config.")
parser.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string passed to uvicorn. Default: {DEFAULT_APP}.")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1.")
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
if args.config:
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
runtime_root = apply_runtime_defaults(config_path)
config = load_server_config(config_path)
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=args.reload_dir)
print(f"Starting GovOPlaN dev server: {args.app}")
print(f"Config: {config_path or DEFAULT_CONFIG}")
if runtime_root is not None:
print(f"Runtime root: {runtime_root}")
print("Modules: " + ", ".join(manifest.id for manifest in registry.manifests()))
if args.no_reload:
print("Reload: disabled")
else:
print("Reload dirs:")
for directory in reload_dirs:
print(f" - {directory}")
try:
import uvicorn
except ModuleNotFoundError as exc:
raise SystemExit("uvicorn is not installed. Install govoplan-core with the server extra or requirements-dev.txt.") from exc
uvicorn.run(
args.app,
host=args.host,
port=args.port,
reload=not args.no_reload,
reload_dirs=reload_dirs if not args.no_reload else None,
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -39,6 +39,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"mail_servers:read": "mail:profile:read", "mail_servers:read": "mail:profile:read",
"mail_servers:use": "mail:profile:use", "mail_servers:use": "mail:profile:use",
"mail_servers:test": "mail:profile:test", "mail_servers:test": "mail:profile:test",
"mail_servers:mailbox_read": "mail:mailbox:read",
"mail_servers:write": "mail:profile:write", "mail_servers:write": "mail:profile:write",
"mail_servers:manage_credentials": "mail:secret:manage", "mail_servers:manage_credentials": "mail:secret:manage",
} }

View File

@@ -19,6 +19,7 @@ class Settings(BaseSettings):
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE") tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES") enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL") s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION") s3_region: str = Field(default="garage", alias="S3_REGION")
@@ -30,6 +31,7 @@ class Settings(BaseSettings):
# production can switch to Garage/S3 without changing API contracts. # production can switch to Garage/S3 without changing API contracts.
file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND") file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND")
file_storage_local_root: str = Field(default="runtime/files", alias="FILE_STORAGE_LOCAL_ROOT") file_storage_local_root: str = Field(default="runtime/files", alias="FILE_STORAGE_LOCAL_ROOT")
file_storage_local_fallback_roots: str = Field(default="", alias="FILE_STORAGE_LOCAL_FALLBACK_ROOTS")
file_storage_s3_endpoint_url: str | None = Field(default=None, alias="FILE_STORAGE_S3_ENDPOINT_URL") file_storage_s3_endpoint_url: str | None = Field(default=None, alias="FILE_STORAGE_S3_ENDPOINT_URL")
file_storage_s3_region: str | None = Field(default=None, alias="FILE_STORAGE_S3_REGION") file_storage_s3_region: str | None = Field(default=None, alias="FILE_STORAGE_S3_REGION")
file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID") file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID")

View File

@@ -0,0 +1,147 @@
import { Folder, FolderOpen } from "lucide-react";
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
export type ExplorerTreeNodeContext = {
depth: number;
active: boolean;
expanded: boolean;
hasChildren: boolean;
disabled: boolean;
};
type ExplorerTreeProps<T> = {
nodes: T[];
getNodeId: (node: T) => string;
getNodeLabel: (node: T) => string;
getNodeChildren: (node: T) => T[];
activeId?: string;
expandedIds: Set<string>;
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
disabled?: boolean;
depth?: number;
className?: string;
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
getNodeDraggable?: (node: T, context: ExplorerTreeNodeContext) => boolean;
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragStart?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragEnd?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragOver?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDragLeave?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
};
export default function ExplorerTree<T>({
nodes,
getNodeId,
getNodeLabel,
getNodeChildren,
activeId = "",
expandedIds,
onOpen,
onToggle,
disabled = false,
depth = 1,
className = "",
renderToggleIcon,
renderNodeContent,
getNodeWrapClassName,
getNodeWrapStyle,
getNodeButtonClassName,
getNodeDraggable,
onContextMenu,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop
}: ExplorerTreeProps<T>) {
if (nodes.length === 0) return null;
return (
<div className={["explorer-tree-children", "file-tree-children", className].filter(Boolean).join(" ")}>
{nodes.map((node) => {
const nodeId = getNodeId(node);
const children = getNodeChildren(node);
const hasChildren = children.length > 0;
const expanded = expandedIds.has(nodeId);
const active = activeId === nodeId;
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
const draggable = getNodeDraggable?.(node, context) ?? false;
return (
<div key={nodeId}>
<div
className={[
"explorer-tree-node-wrap",
"file-tree-node-wrap",
active ? "is-active" : "",
getNodeWrapClassName?.(node, context) ?? ""
].filter(Boolean).join(" ")}
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
draggable={draggable && !disabled}
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
>
<button
type="button"
className="explorer-tree-toggle file-tree-toggle"
onClick={(event) => {
event.stopPropagation();
if (hasChildren) onToggle(node, context);
}}
disabled={disabled || !hasChildren}
aria-label={`${expanded ? "Collapse" : "Expand"} ${getNodeLabel(node)}`}
aria-expanded={hasChildren ? expanded : undefined}
>
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
</button>
<button
type="button"
className={["explorer-tree-node", "file-tree-node", getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
onClick={() => onOpen(node, context)}
disabled={disabled}
>
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
</button>
</div>
{hasChildren && expanded && (
<ExplorerTree
nodes={children}
getNodeId={getNodeId}
getNodeLabel={getNodeLabel}
getNodeChildren={getNodeChildren}
activeId={activeId}
expandedIds={expandedIds}
onOpen={onOpen}
onToggle={onToggle}
disabled={disabled}
depth={depth + 1}
renderToggleIcon={renderToggleIcon}
renderNodeContent={renderNodeContent}
getNodeWrapClassName={getNodeWrapClassName}
getNodeWrapStyle={getNodeWrapStyle}
getNodeButtonClassName={getNodeButtonClassName}
getNodeDraggable={getNodeDraggable}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
/>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { Paperclip } from "lucide-react";
export type MessageDisplayField = {
label: string;
value?: string | null;
};
export type MessageDisplayAttachment = {
id?: string | null;
filename?: string | null;
contentType: string;
sizeBytes: number;
};
type MessageDisplayPanelProps = {
title?: string | null;
fields?: MessageDisplayField[];
bodyText?: string | null;
bodyHtml?: string | null;
bodyPreview?: string | null;
headers?: Record<string, string>;
attachments?: MessageDisplayAttachment[];
emptyText?: string;
};
export default function MessageDisplayPanel({
title,
fields = [],
bodyText,
bodyHtml,
bodyPreview,
headers = {},
attachments = [],
emptyText = "Select an item to inspect its content."
}: MessageDisplayPanelProps) {
if (!title && fields.length === 0 && !bodyText && !bodyHtml && !bodyPreview && attachments.length === 0 && Object.keys(headers).length === 0) {
return <p className="muted">{emptyText}</p>;
}
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
return (
<div className="message-display-panel">
<div className="message-display-header">
<h3>{title || "(no subject)"}</h3>
{fields.filter((field) => field.value).map((field) => (
<div key={field.label}>
<span>{field.label}</span>
<strong>{field.value}</strong>
</div>
))}
</div>
<pre className="message-display-body">{body || "No readable body content."}</pre>
{attachments.length ? (
<div className="message-display-attachments">
<h4>Attachments</h4>
{attachments.map((attachment, index) => (
<div key={attachment.id || `${attachment.filename || "attachment"}-${index}`}>
<Paperclip size={14} aria-hidden="true" />
<span>{attachment.filename || "unnamed attachment"}</span>
<small>{attachment.contentType} - {formatBytes(attachment.sizeBytes)}</small>
</div>
))}
</div>
) : null}
<details className="message-display-headers">
<summary>Headers</summary>
<dl>
{Object.entries(headers).map(([key, value]) => (
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
))}
</dl>
</details>
</div>
);
}
function formatBytes(value?: number | null): string {
if (!value) return "-";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function stripHtml(value: string): string {
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}

View File

@@ -0,0 +1,257 @@
import type { ReactNode } from "react";
import Button from "../Button";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import ToggleSwitch from "../ToggleSwitch";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
export type MailServerSmtpSettings = {
host?: string | null;
port?: string | number | null;
username?: string | null;
password?: string | null;
security?: MailServerSecurity | null;
timeout_seconds?: string | number | null;
};
export type MailServerImapSettings = MailServerSmtpSettings & {
enabled?: boolean;
sent_folder?: string | null;
};
export type MailServerConnectionTestResult = {
ok: boolean;
protocol?: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailServerSecurity | null;
message: string;
details?: Record<string, unknown> | null;
};
export type MailServerFolderLookupResult = {
ok: boolean;
protocol?: "imap";
host?: string | null;
port?: number | null;
security?: MailServerSecurity | null;
message: string;
detected_sent_folder?: string | null;
folders?: { name: string; flags?: string[] }[];
details?: Record<string, unknown> | null;
};
export type MailServerSettingsPanelProps = {
smtp: MailServerSmtpSettings;
imap: MailServerImapSettings;
onSmtpChange: (patch: Partial<MailServerSmtpSettings>) => void;
onImapChange: (patch: Partial<MailServerImapSettings>) => void;
onImapEnabledChange?: (enabled: boolean) => void;
smtpDisabled?: boolean;
smtpCredentialDisabled?: boolean;
smtpActionDisabled?: boolean;
imapToggleDisabled?: boolean;
imapServerDisabled?: boolean;
imapCredentialDisabled?: boolean;
imapActionDisabled?: boolean;
smtpTitle?: ReactNode;
imapTitle?: ReactNode;
smtpTestLabel?: string;
imapTestLabel?: string;
folderLookupLabel?: string;
busyAction?: "smtp" | "imap" | "folders" | string | null;
onTestSmtp?: () => void;
onTestImap?: () => void;
onLookupFolders?: () => void;
smtpTestResult?: MailServerConnectionTestResult | null;
imapTestResult?: MailServerConnectionTestResult | null;
folderLookupResult?: MailServerFolderLookupResult | null;
onUseDetectedFolder?: () => void;
useDetectedFolderDisabled?: boolean;
mockToggle?: {
label?: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
};
append?: {
enabled: boolean;
folder: string;
disabled?: boolean;
folderDisabled?: boolean;
onEnabledChange: (enabled: boolean) => void;
onFolderChange: (folder: string) => void;
};
disabled?: boolean;
className?: string;
floatingResults?: boolean;
};
const securityOptions = ["plain", "tls", "starttls"];
export default function MailServerSettingsPanel({
smtp,
imap,
onSmtpChange,
onImapChange,
onImapEnabledChange,
smtpDisabled = false,
smtpCredentialDisabled = smtpDisabled,
smtpActionDisabled = smtpDisabled,
imapToggleDisabled = false,
imapServerDisabled = false,
imapCredentialDisabled = imapServerDisabled,
imapActionDisabled = imapServerDisabled,
smtpTitle = "SMTP login",
imapTitle = "IMAP sent-folder append",
smtpTestLabel = "Test SMTP",
imapTestLabel = "Test IMAP",
folderLookupLabel = "Folders...",
busyAction = null,
onTestSmtp,
onTestImap,
onLookupFolders,
smtpTestResult = null,
imapTestResult = null,
folderLookupResult = null,
onUseDetectedFolder,
useDetectedFolderDisabled = false,
mockToggle,
append,
disabled = false,
className = "",
floatingResults = false
}: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
const smtpActionsDisabled = disabled || smtpActionDisabled;
const imapFieldsDisabled = disabled || imapServerDisabled;
const imapCredentialFieldsDisabled = disabled || imapCredentialDisabled;
const imapActionsDisabled = disabled || imapActionDisabled;
return (
<div className={`mail-server-settings-panel ${className}`.trim()}>
<div className="mail-server-settings-grid">
<section className="mail-server-subsection">
<div className="mail-server-section-heading split">
<h3>{smtpTitle}</h3>
{mockToggle && (
<ToggleSwitch
label={mockToggle.label ?? "Mock server settings"}
checked={mockToggle.checked}
disabled={disabled || mockToggle.disabled}
onChange={mockToggle.onChange}
/>
)}
</div>
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
<FormField label="Password"><input type="password" value={stringValue(smtp.password)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ password: event.target.value })} /></FormField>
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
</div>
{onTestSmtp && (
<div className="button-row compact-actions mail-server-actions">
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"}>{busyAction === "smtp" ? "Testing..." : smtpTestLabel}</Button>
</div>
)}
<MailServerActionResult result={smtpTestResult} floating={floatingResults} />
</section>
<section className="mail-server-subsection">
<div className="mail-server-section-heading split">
<h3>{imapTitle}</h3>
</div>
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{onImapEnabledChange && (
<div className="mail-server-field-span mail-server-toggle-row">
<ToggleSwitch label="Enable IMAP" checked={Boolean(imap.enabled)} disabled={disabled || imapToggleDisabled} onChange={onImapEnabledChange} />
</div>
)}
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
<FormField label="Password"><input type="password" value={stringValue(imap.password)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ password: event.target.value })} /></FormField>
<FormField label="Security"><SecuritySelect value={stringValue(imap.security, "tls")} disabled={imapFieldsDisabled} onChange={(security) => onImapChange({ security })} /></FormField>
<FormField label="Detected/saved sent folder"><input value={stringValue(imap.sent_folder, "auto")} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ sent_folder: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
{append && (
<>
<div className="mail-server-field-span mail-server-toggle-row">
<ToggleSwitch label="Append successfully sent messages to Sent" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
</div>
<FormField label="Append folder"><input value={append.folder} disabled={disabled || append.folderDisabled} onChange={(event) => append.onFolderChange(event.target.value)} /></FormField>
</>
)}
</div>
{(onTestImap || onLookupFolders) && (
<div className="button-row compact-actions mail-server-actions">
{onTestImap && <Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"}>{busyAction === "imap" ? "Testing..." : imapTestLabel}</Button>}
{onLookupFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "Looking up..." : folderLookupLabel}</Button>}
</div>
)}
{onLookupFolders && <p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>}
<MailServerActionResult result={imapTestResult} floating={floatingResults} />
<MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled} onUseDetected={onUseDetectedFolder} />
</section>
</div>
</div>
);
}
export function MailServerActionResult({ result, floating = false }: { result: MailServerConnectionTestResult | null | undefined; floating?: boolean }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating={floating}>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</DismissibleAlert>
);
}
export function MailServerFolderLookupResultView({
result,
disabled = false,
onUseDetected
}: {
result: MailServerFolderLookupResult | null | undefined;
disabled?: boolean;
onUseDetected?: () => void;
}) {
if (!result) return null;
if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
}
const folders = result.folders ?? [];
return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "-"}</strong></p>
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{folders.length > 0 && (
<div className="mail-server-folder-chip-list">
{folders.slice(0, 12).map((folder) => (
<span className="mail-server-folder-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{folders.length > 12 && <span className="mail-server-folder-chip">+{folders.length - 12} more</span>}
</div>
)}
</DismissibleAlert>
);
}
function SecuritySelect({ value, disabled, onChange }: { value: string; disabled: boolean; onChange: (value: MailServerSecurity) => void }) {
return <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
}
function stringValue(value: string | number | null | undefined, fallback = ""): string {
if (value === null || value === undefined) return fallback;
return String(value);
}

View File

@@ -1,3 +1,5 @@
import { formatDateTime as formatPlatformDateTime } from "../../utils/datetime";
export function adminErrorMessage(error: unknown): string { export function adminErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "The administrative operation failed."; if (!(error instanceof Error)) return "The administrative operation failed.";
const message = error.message; const message = error.message;
@@ -17,9 +19,7 @@ export function adminErrorMessage(error: unknown): string {
} }
export function formatDateTime(value?: string | null): string { export function formatDateTime(value?: string | null): string {
if (!value) return "Never"; return formatPlatformDateTime(value, { fallback: "Never" });
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
} }
export function joinLabels(values: Array<{ name: string }>): string { export function joinLabels(values: Array<{ name: string }>): string {

View File

@@ -8,6 +8,7 @@ export * from "./utils/permissions";
export * from "./utils/fieldHelp"; export * from "./utils/fieldHelp";
export * from "./utils/helpContext"; export * from "./utils/helpContext";
export * from "./utils/emailAddresses"; export * from "./utils/emailAddresses";
export * from "./utils/datetime";
export * from "./utils/arrayOrder"; export * from "./utils/arrayOrder";
export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary"; export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
@@ -19,12 +20,18 @@ export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as FormField } from "./components/FormField"; export { default as FormField } from "./components/FormField";
export { default as LoadingFrame } from "./components/LoadingFrame"; export { default as LoadingFrame } from "./components/LoadingFrame";
export { default as LoadingIndicator } from "./components/LoadingIndicator"; export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree";
export type { ExplorerTreeNodeContext } from "./components/ExplorerTree";
export { default as MetricCard } from "./components/MetricCard"; export { default as MetricCard } from "./components/MetricCard";
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle"; export { default as PageTitle } from "./components/PageTitle";
export { default as StatusBadge } from "./components/StatusBadge"; export { default as StatusBadge } from "./components/StatusBadge";
export { default as Stepper } from "./components/Stepper"; export { default as Stepper } from "./components/Stepper";
export { default as ToggleSwitch } from "./components/ToggleSwitch"; export { default as ToggleSwitch } from "./components/ToggleSwitch";
export { default as EmailAddressInput } from "./components/email/EmailAddressInput"; export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView } from "./components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
export { default as FieldLabel } from "./components/help/FieldLabel"; export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp"; export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid"; export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";

View File

@@ -1,4 +1,4 @@
import { Activity, FileText, Folder, Form, LayoutDashboard, MailCheck, Users, type LucideIcon } from "lucide-react"; import { Activity, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Users, type LucideIcon } from "lucide-react";
import campaignModule from "@govoplan/campaign-webui"; import campaignModule from "@govoplan/campaign-webui";
import filesModule from "@govoplan/files-webui"; import filesModule from "@govoplan/files-webui";
import mailModule from "@govoplan/mail-webui"; import mailModule from "@govoplan/mail-webui";
@@ -6,28 +6,41 @@ import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformRouteContri
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions"; import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
export const shellNavItems: PlatformNavItem[] = [ export const shellNavItems: PlatformNavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, order: 10 } { to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 }
]; ];
const legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule]; const legacyModules: PlatformWebModule[] = [campaignModule, filesModule, mailModule];
const iconByName: Record<string, LucideIcon> = { const iconByName: Record<string, LucideIcon> = {
activity: Activity, activity: Activity,
campaign: MailCheck, campaign: Mails,
dashboard: LayoutDashboard,
file: Folder, file: Folder,
files: Folder, files: Folder,
folder: Folder, folder: Folder,
form: Form, form: Form,
mail: MailCheck, mail: Mail,
reports: FileText, reports: FileText,
users: Users users: Users
}; };
export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined {
if (!iconName) return undefined;
return iconByName[iconName];
}
function resolveNavItemIcon(item: PlatformNavItem): PlatformNavItem {
return {
...item,
icon: item.icon ?? iconComponentForName(item.iconName)
};
}
function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem { function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem {
return { return {
to: item.path, to: item.path,
label: item.label, label: item.label,
icon: item.icon ? iconByName[item.icon] : undefined, iconName: item.icon ?? null,
allOf: item.required_all, allOf: item.required_all,
anyOf: item.required_any, anyOf: item.required_any,
order: item.order order: item.order
@@ -70,7 +83,9 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
} }
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] { export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])].sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); return [...shellNavItems, ...modules.flatMap((module) => module.navItems ?? [])]
.map(resolveNavItemIcon)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
} }
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] { export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {

View File

@@ -627,3 +627,234 @@
opacity: 1; opacity: 1;
background: rgba(255, 255, 255, 0.45); background: rgba(255, 255, 255, 0.45);
} }
/* Shared explorer tree and message display surfaces. */
.explorer-tree-children {
display: grid;
gap: 1px;
}
.explorer-tree-node-wrap {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 2px;
border-radius: 9px;
}
.explorer-tree-node,
.explorer-tree-toggle {
border: 0;
background: transparent;
color: var(--text);
cursor: pointer;
}
.explorer-tree-toggle {
display: grid;
place-items: center;
width: 26px;
height: 32px;
border-radius: 9px;
color: var(--muted);
}
.explorer-tree-toggle:disabled {
cursor: default;
opacity: .55;
}
.explorer-tree-node {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
padding: 8px 9px;
border-radius: 9px;
font: inherit;
text-align: left;
user-select: none;
}
.explorer-tree-node span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorer-tree-node-wrap:hover,
.explorer-tree-node-wrap:focus-within,
.explorer-tree-node-wrap.is-active {
background: rgba(13, 110, 253, .08);
outline: none;
}
.explorer-tree-node:focus-visible,
.explorer-tree-toggle:focus-visible {
outline: none;
}
.message-display-panel {
display: grid;
gap: 14px;
min-width: 0;
}
.message-display-header {
display: grid;
gap: 8px;
}
.message-display-header h3 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.message-display-header div {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 10px;
}
.message-display-header span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.message-display-header strong {
min-width: 0;
overflow-wrap: anywhere;
}
.message-display-body {
max-height: 420px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
color: var(--text);
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message-display-attachments {
display: grid;
gap: 8px;
}
.message-display-attachments h4 {
margin: 0;
}
.message-display-attachments div {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.message-display-attachments small {
color: var(--muted);
}
.message-display-headers dl {
display: grid;
gap: 7px;
max-height: 280px;
overflow: auto;
}
.message-display-headers dl div {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 10px;
}
.message-display-headers dt {
color: var(--muted);
font-weight: 700;
}
.message-display-headers dd {
margin: 0;
overflow-wrap: anywhere;
}
/* Shared SMTP/IMAP server settings panel. */
.mail-server-settings-panel {
display: grid;
gap: 12px;
}
.mail-server-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 22px;
}
.mail-server-subsection {
display: grid;
align-content: start;
gap: 12px;
}
.mail-server-section-heading.split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mail-server-section-heading h3 {
margin: 0;
color: var(--text-strong);
font-size: 14px;
letter-spacing: .01em;
}
.mail-server-form-grid {
align-items: start;
}
.mail-server-field-span {
grid-column: 1 / -1;
}
.mail-server-toggle-row {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.mail-server-actions {
justify-content: flex-end;
margin-top: 12px;
}
.mail-server-folder-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mail-server-folder-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
border: 1px solid var(--line);
border-radius: 99px;
background: var(--panel-soft);
padding: 3px 9px;
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 1000px) {
.mail-server-settings-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
}

View File

@@ -112,9 +112,14 @@ export type CampaignListItem = {
}; };
export type PlatformIconName = string;
export type PlatformNavItem = { export type PlatformNavItem = {
to: string; to: string;
label: string; label: string;
/** Resolved by core. Modules should provide iconName, not imported icon components. */
iconName?: PlatformIconName | null;
/** Runtime-renderable icon, assigned by core after resolving iconName. */
icon?: ComponentType<{ size?: number }>; icon?: ComponentType<{ size?: number }>;
anyOf?: string[]; anyOf?: string[];
allOf?: string[]; allOf?: string[];

View File

@@ -0,0 +1,46 @@
const HAS_TIMEZONE_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
function normalizeServerDateTime(value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
return HAS_TIMEZONE_OFFSET.test(trimmed) ? trimmed : `${trimmed}Z`;
}
export function parseServerDateTime(value?: string | null): Date | null {
if (!value) return null;
const date = new Date(normalizeServerDateTime(value));
return Number.isNaN(date.getTime()) ? null : date;
}
export type FormatDateTimeOptions = Intl.DateTimeFormatOptions & {
fallback?: string;
};
export const defaultDateTimeFormatOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short"
};
export function formatDateTime(value?: string | null, options: FormatDateTimeOptions = {}): string {
const fallback = options.fallback ?? "—";
if (!value) return fallback;
const date = parseServerDateTime(value);
if (!date) return value;
const { fallback: _fallback, ...intlOptions } = options;
return date.toLocaleString(undefined, {
...defaultDateTimeFormatOptions,
...intlOptions
});
}
export function formatDateTimeFromDate(value: Date, options: FormatDateTimeOptions = {}): string {
const { fallback: _fallback, ...intlOptions } = options;
return value.toLocaleString(undefined, {
...defaultDateTimeFormatOptions,
...intlOptions
});
}

View File

@@ -6,7 +6,7 @@ const legacyTenantScopes = [
"recipients:read", "recipients:write", "recipients:import", "recipients:export", "recipients:read", "recipients:write", "recipients:import", "recipients:export",
"files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin", "files:read", "files:download", "files:upload", "files:organize", "files:share", "files:delete", "files:admin",
"reports:read", "reports:export", "reports:send", "audit:read", "reports:read", "reports:export", "reports:send", "audit:read",
"mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:write", "mail_servers:manage_credentials", "mail_servers:read", "mail_servers:use", "mail_servers:test", "mail_servers:mailbox_read", "mail_servers:write", "mail_servers:manage_credentials",
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend", "admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members", "admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign", "admin:roles:read", "admin:roles:write", "admin:roles:assign",
@@ -48,6 +48,7 @@ const legacyToModuleScopes: Record<string, string> = {
"mail_servers:read": "mail:profile:read", "mail_servers:read": "mail:profile:read",
"mail_servers:use": "mail:profile:use", "mail_servers:use": "mail:profile:use",
"mail_servers:test": "mail:profile:test", "mail_servers:test": "mail:profile:test",
"mail_servers:mailbox_read": "mail:mailbox:read",
"mail_servers:write": "mail:profile:write", "mail_servers:write": "mail:profile:write",
"mail_servers:manage_credentials": "mail:secret:manage" "mail_servers:manage_credentials": "mail:secret:manage"
}; };

View File

@@ -2,8 +2,17 @@ import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
const linkedModulePackages = [
"@govoplan/campaign-webui",
"@govoplan/files-webui",
"@govoplan/mail-webui"
];
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
optimizeDeps: {
exclude: linkedModulePackages
},
resolve: { resolve: {
preserveSymlinks: true, preserveSymlinks: true,
dedupe: ["react", "react-dom", "react-router-dom"], dedupe: ["react", "react-dom", "react-router-dom"],