From 71f4265e06311546aa597d5099918eb2de4f1f6e Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 21 Apr 2026 23:49:46 +0200
Subject: [PATCH] fixes udb, outlook, workflow
---
.../connectors/providerMsft/connectorMsft.py | 269 ++++++++++++
modules/datamodels/datamodelFiles.py | 9 +-
modules/datamodels/datamodelUam.py | 25 +-
modules/features/chatbot/mainChatbot.py | 14 +-
modules/features/commcoach/mainCommcoach.py | 40 +-
.../graphicalEditor/mainGraphicalEditor.py | 18 +-
.../neutralization/mainNeutralization.py | 12 +-
modules/features/realEstate/mainRealEstate.py | 13 +-
modules/features/redmine/mainRedmine.py | 34 +-
modules/features/teamsbot/mainTeamsbot.py | 26 +-
.../trustee/datamodelFeatureTrustee.py | 34 +-
modules/features/trustee/mainTrustee.py | 100 ++---
modules/features/workspace/mainWorkspace.py | 26 +-
modules/interfaces/interfaceBootstrap.py | 1 +
.../coreTools/_dataSourceTools.py | 17 +-
.../serviceAgent/coreTools/_emailTools.py | 401 ++++++++++++++++++
.../serviceAgent/coreTools/registerCore.py | 2 +
.../services/serviceAgent/toolboxRegistry.py | 16 +-
modules/shared/attributeUtils.py | 39 ++
modules/shared/i18nRegistry.py | 12 +
modules/system/mainSystem.py | 7 +-
.../test_featureCatalogLabels_i18n.py | 58 +++
22 files changed, 1016 insertions(+), 157 deletions(-)
create mode 100644 modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py
create mode 100644 tests/validation/test_featureCatalogLabels_i18n.py
diff --git a/modules/connectors/providerMsft/connectorMsft.py b/modules/connectors/providerMsft/connectorMsft.py
index 50f92249..30caba95 100644
--- a/modules/connectors/providerMsft/connectorMsft.py
+++ b/modules/connectors/providerMsft/connectorMsft.py
@@ -34,6 +34,9 @@ class _GraphApiMixin:
async def _graphPut(self, endpoint: str, data: bytes = None) -> Dict[str, Any]:
return await _makeGraphCall(self._accessToken, endpoint, "PUT", data)
+ async def _graphPatch(self, endpoint: str, data: Any = None) -> Dict[str, Any]:
+ return await _makeGraphCall(self._accessToken, endpoint, "PATCH", data)
+
async def _graphDelete(self, endpoint: str) -> Dict[str, Any]:
return await _makeGraphCall(self._accessToken, endpoint, "DELETE")
@@ -82,6 +85,9 @@ async def _makeGraphCall(
elif method == "PUT":
async with session.put(url, **kwargs) as resp:
return await _handleResponse(resp)
+ elif method == "PATCH":
+ async with session.patch(url, **kwargs) as resp:
+ return await _handleResponse(resp)
elif method == "DELETE":
async with session.delete(url, **kwargs) as resp:
if resp.status in (200, 204):
@@ -99,6 +105,10 @@ async def _makeGraphCall(
async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
if resp.status in (200, 201):
return await resp.json()
+ if resp.status == 202:
+ return {"accepted": True}
+ if resp.status == 204:
+ return {}
errorText = await resp.text()
logger.error(f"Graph API {resp.status}: {errorText}")
return {"error": f"{resp.status}: {errorText}"}
@@ -449,6 +459,265 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
return result
return {"success": True, "draft": True, "messageId": result.get("id", "")}
+ # ------------------------------------------------------------------
+ # Reply / Reply-All / Forward
+ # ------------------------------------------------------------------
+ # Microsoft Graph distinguishes between "send-immediately" endpoints
+ # (``/reply``, ``/replyAll``, ``/forward``) and their "create-draft"
+ # counterparts (``/createReply``, ``/createReplyAll``, ``/createForward``).
+ # The send-immediately variant accepts a free-text ``comment`` string
+ # that Graph prepends to the original conversation; the createReply*
+ # variants return a fully-populated draft message that the caller can
+ # further edit (e.g. via PATCH /me/messages/{id} with a richer body)
+ # before posting via /send. We expose both flavours so the agent can
+ # choose between "draft for review" and "send right now".
+
+ async def replyToMail(
+ self, messageId: str, comment: str,
+ replyAll: bool = False,
+ ) -> Dict[str, Any]:
+ """Reply (or reply-all) to an existing message immediately.
+
+ Preserves the conversation thread and the ``AW:`` prefix in Outlook --
+ unlike sendMail() which creates a brand-new conversation.
+ """
+ import json
+ endpointAction = "replyAll" if replyAll else "reply"
+ payload = json.dumps({"comment": comment}).encode("utf-8")
+ result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "messageId": messageId, "action": endpointAction}
+
+ async def forwardMail(
+ self, messageId: str, to: List[str], comment: str = "",
+ ) -> Dict[str, Any]:
+ """Forward an existing message to new recipients."""
+ import json
+ payload = json.dumps({
+ "comment": comment,
+ "toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
+ }).encode("utf-8")
+ result = await self._graphPost(f"me/messages/{messageId}/forward", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "messageId": messageId, "action": "forward"}
+
+ async def createReplyDraft(
+ self, messageId: str, comment: str = "",
+ replyAll: bool = False,
+ ) -> Dict[str, Any]:
+ """Create a reply-draft (in the Drafts folder) that the user can edit before sending."""
+ import json
+ endpointAction = "createReplyAll" if replyAll else "createReply"
+ payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}"
+ result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "draft": True, "messageId": result.get("id", ""), "originalMessageId": messageId}
+
+ async def createForwardDraft(
+ self, messageId: str, to: Optional[List[str]] = None, comment: str = "",
+ ) -> Dict[str, Any]:
+ """Create a forward-draft (in the Drafts folder) that the user can edit before sending."""
+ import json
+ body: Dict[str, Any] = {}
+ if comment:
+ body["comment"] = comment
+ if to:
+ body["toRecipients"] = [{"emailAddress": {"address": addr}} for addr in to]
+ payload = json.dumps(body).encode("utf-8") if body else b"{}"
+ result = await self._graphPost(f"me/messages/{messageId}/createForward", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "draft": True, "messageId": result.get("id", ""), "originalMessageId": messageId}
+
+ # ------------------------------------------------------------------
+ # Folder-Management & Mail-Management
+ # ------------------------------------------------------------------
+
+ # Mapping of Microsoft Graph "well-known folder names" plus a few common
+ # localized display names (DE) so the LLM can write natural names like
+ # "Posteingang", "Archiv", "deletedItems" without having to look up the
+ # opaque mailbox folder ID first.
+ _WELL_KNOWN_FOLDERS = {
+ "inbox": "inbox",
+ "posteingang": "inbox",
+ "drafts": "drafts",
+ "entwürfe": "drafts",
+ "entwurf": "drafts",
+ "sentitems": "sentitems",
+ "gesendet": "sentitems",
+ "gesendete elemente": "sentitems",
+ "deleteditems": "deleteditems",
+ "gelöscht": "deleteditems",
+ "gelöschte elemente": "deleteditems",
+ "papierkorb": "deleteditems",
+ "trash": "deleteditems",
+ "junkemail": "junkemail",
+ "spam": "junkemail",
+ "junk": "junkemail",
+ "outbox": "outbox",
+ "postausgang": "outbox",
+ "archive": "archive",
+ "archiv": "archive",
+ "msgfolderroot": "msgfolderroot",
+ "root": "msgfolderroot",
+ }
+
+ async def listMailFolders(self) -> List[Dict[str, Any]]:
+ """List all top-level mail folders with id, name and counts.
+
+ Returns a flat list of dicts so the caller (e.g. an LLM tool) does not
+ need to know the Graph nesting model. Use ``_resolveFolderId()`` to
+ translate a user-provided name into a Graph folder ID.
+ """
+ folders: List[Dict[str, Any]] = []
+ seenIds: set = set()
+ endpoint: Optional[str] = "me/mailFolders?$top=100"
+ while endpoint:
+ result = await self._graphGet(endpoint)
+ if "error" in result:
+ break
+ for f in result.get("value", []):
+ fid = f.get("id")
+ if fid and fid not in seenIds:
+ seenIds.add(fid)
+ folders.append({
+ "id": fid,
+ "displayName": f.get("displayName", ""),
+ "totalItemCount": f.get("totalItemCount", 0),
+ "unreadItemCount": f.get("unreadItemCount", 0),
+ "childFolderCount": f.get("childFolderCount", 0),
+ })
+ nextLink = result.get("@odata.nextLink")
+ endpoint = _stripGraphBase(nextLink) if nextLink else None
+ return folders
+
+ async def _resolveFolderId(self, folderRef: str) -> Optional[str]:
+ """Resolve any user-supplied folder reference to a Graph folder ID.
+
+ Resolution order:
+ 1. If it matches a well-known shortcut (locale-aware), return that
+ shortcut directly -- Graph accepts ``inbox``, ``drafts`` etc. in
+ the URL path.
+ 2. If it looks like a Graph folder ID (long base64-ish string),
+ return as-is.
+ 3. Otherwise fall back to a case-insensitive ``displayName`` match
+ against the user's mail folders.
+
+ Returns ``None`` if nothing matches so the caller can surface a clear
+ error instead of silently moving mail into the wrong place.
+ """
+ if not folderRef:
+ return None
+ ref = folderRef.strip()
+ wellKnown = self._WELL_KNOWN_FOLDERS.get(ref.lower())
+ if wellKnown:
+ return wellKnown
+ # Heuristic: Graph folder IDs are long URL-safe base64 strings; never
+ # contain spaces; and almost always include "==" or AAAAA padding.
+ if len(ref) > 60 and " " not in ref:
+ return ref
+ for f in await self.listMailFolders():
+ if (f.get("displayName") or "").strip().lower() == ref.lower():
+ return f.get("id")
+ return None
+
+ async def moveMail(
+ self, messageId: str, destinationFolder: str,
+ ) -> Dict[str, Any]:
+ """Move a message to another folder (well-known name, displayName, or folder id)."""
+ import json
+ destId = await self._resolveFolderId(destinationFolder)
+ if not destId:
+ return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
+ payload = json.dumps({"destinationId": destId}).encode("utf-8")
+ result = await self._graphPost(f"me/messages/{messageId}/move", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "messageId": result.get("id", messageId), "destinationFolder": destinationFolder}
+
+ async def copyMail(
+ self, messageId: str, destinationFolder: str,
+ ) -> Dict[str, Any]:
+ """Copy a message into another folder (original stays in place)."""
+ import json
+ destId = await self._resolveFolderId(destinationFolder)
+ if not destId:
+ return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
+ payload = json.dumps({"destinationId": destId}).encode("utf-8")
+ result = await self._graphPost(f"me/messages/{messageId}/copy", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "newMessageId": result.get("id", ""), "destinationFolder": destinationFolder}
+
+ async def archiveMail(self, messageId: str) -> Dict[str, Any]:
+ """Move a message to the user's Archive folder.
+
+ Outlook's Archive is a regular mail folder, not a flag, so this is a
+ thin convenience wrapper around :py:meth:`moveMail`.
+ """
+ return await self.moveMail(messageId, "archive")
+
+ async def deleteMail(
+ self, messageId: str,
+ *,
+ hardDelete: bool = False,
+ ) -> Dict[str, Any]:
+ """Delete a message.
+
+ Default behaviour (``hardDelete=False``) moves the message to the
+ ``Deleted Items`` folder, which mirrors what users see in the Outlook
+ UI when they press Delete. Set ``hardDelete=True`` to perform an
+ unrecoverable removal -- agent tools must require an extra
+ confirmation before invoking this path.
+ """
+ if hardDelete:
+ result = await self._graphDelete(f"me/messages/{messageId}")
+ if "error" in result:
+ return result
+ return {"success": True, "messageId": messageId, "hardDelete": True}
+ return await self.moveMail(messageId, "deleteditems")
+
+ async def markMailAsRead(self, messageId: str) -> Dict[str, Any]:
+ """Mark a message as read (sets ``isRead=true``)."""
+ import json
+ payload = json.dumps({"isRead": True}).encode("utf-8")
+ result = await self._graphPatch(f"me/messages/{messageId}", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "messageId": messageId, "isRead": True}
+
+ async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]:
+ """Mark a message as unread (sets ``isRead=false``)."""
+ import json
+ payload = json.dumps({"isRead": False}).encode("utf-8")
+ result = await self._graphPatch(f"me/messages/{messageId}", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "messageId": messageId, "isRead": False}
+
+ async def flagMail(
+ self, messageId: str,
+ *,
+ flagStatus: str = "flagged",
+ ) -> Dict[str, Any]:
+ """Set or clear the follow-up flag on a message.
+
+ ``flagStatus`` accepts ``"flagged"`` (default), ``"complete"`` or
+ ``"notFlagged"`` -- the three values Microsoft Graph recognises for
+ ``followupFlag.flagStatus``.
+ """
+ import json
+ if flagStatus not in ("flagged", "complete", "notFlagged"):
+ return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."}
+ payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8")
+ result = await self._graphPatch(f"me/messages/{messageId}", payload)
+ if "error" in result:
+ return result
+ return {"success": True, "messageId": messageId, "flagStatus": flagStatus}
+
# ---------------------------------------------------------------------------
# Teams Adapter (Stub)
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index 4cc6beba..c8b0c865 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -60,7 +60,14 @@ class FileItem(PowerOnModel):
)
fileSize: int = Field(
description="Size of the file in bytes",
- json_schema_extra={"label": "Dateigroesse", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False},
+ json_schema_extra={
+ "label": "Dateigroesse",
+ "frontend_type": "integer",
+ "frontend_readonly": True,
+ "frontend_required": False,
+ # Auto-scale byte units (B / KB / MB / GB / TB), right-aligned in tables.
+ "frontend_format": "R:b",
+ },
)
tags: Optional[List[str]] = Field(
default=None,
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index b78e22c5..90dd9452 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -162,12 +162,25 @@ class Mandate(PowerOnModel):
enabled: bool = Field(
default=True,
description="Indicates whether the mandate is enabled",
- json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
+ json_schema_extra={
+ "frontend_type": "checkbox",
+ "frontend_readonly": False,
+ "frontend_required": False,
+ "label": "Aktiviert",
+ # Render boolean as i18n-translatable label tuple [true, neutral, false].
+ "frontend_format_labels": ["Ja", "-", "Nein"],
+ },
)
isSystem: bool = Field(
default=False,
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
- json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False, "label": "System-Mandant"},
+ json_schema_extra={
+ "frontend_type": "checkbox",
+ "frontend_readonly": True,
+ "frontend_required": False,
+ "label": "System-Mandant",
+ "frontend_format_labels": ["Ja", "-", "Nein"],
+ },
)
deletedAt: Optional[float] = Field(
default=None,
@@ -546,7 +559,13 @@ class User(PowerOnModel):
enabled: bool = Field(
default=True,
description="Indicates whether the user is enabled",
- json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
+ json_schema_extra={
+ "frontend_type": "checkbox",
+ "frontend_readonly": False,
+ "frontend_required": False,
+ "label": "Aktiviert",
+ "frontend_format_labels": ["Ja", "-", "Nein"],
+ },
)
isSysAdmin: bool = Field(
diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py
index 6f3b7dae..7b3b6e0c 100644
--- a/modules/features/chatbot/mainChatbot.py
+++ b/modules/features/chatbot/mainChatbot.py
@@ -8,18 +8,20 @@ Handles feature initialization and RBAC catalog registration.
import logging
from typing import Dict, List, Any, Optional
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "chatbot"
-FEATURE_LABEL = "Chatbot"
+FEATURE_LABEL = t("Chatbot", context="UI")
FEATURE_ICON = "mdi-robot"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.chatbot.conversations",
- "label": "Konversationen",
+ "label": t("Konversationen", context="UI"),
"meta": {"area": "conversations"}
}
]
@@ -28,22 +30,22 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.chatbot.startStream",
- "label": "Chat starten (Stream)",
+ "label": t("Chat starten (Stream)", context="UI"),
"meta": {"endpoint": "/api/chatbot/{instanceId}/start/stream", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbot.stop",
- "label": "Chat stoppen",
+ "label": t("Chat stoppen", context="UI"),
"meta": {"endpoint": "/api/chatbot/{instanceId}/stop/{workflowId}", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbot.threads",
- "label": "Threads abrufen",
+ "label": t("Threads abrufen", context="UI"),
"meta": {"endpoint": "/api/chatbot/{instanceId}/threads", "method": "GET"}
},
{
"objectKey": "resource.feature.chatbot.delete",
- "label": "Chat löschen",
+ "label": t("Chat löschen", context="UI"),
"meta": {"endpoint": "/api/chatbot/{instanceId}/{workflowId}", "method": "DELETE"}
},
]
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index d27b2090..33469a62 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -8,26 +8,28 @@ Handles feature initialization and RBAC catalog registration.
import logging
from typing import Dict, List, Any
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
FEATURE_CODE = "commcoach"
-FEATURE_LABEL = "Kommunikations-Coach"
+FEATURE_LABEL = t("Kommunikations-Coach", context="UI")
FEATURE_ICON = "mdi-account-voice"
UI_OBJECTS = [
{
"objectKey": "ui.feature.commcoach.dashboard",
- "label": "Dashboard",
+ "label": t("Dashboard", context="UI"),
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.commcoach.coaching",
- "label": "Arbeitsthemen",
+ "label": t("Arbeitsthemen", context="UI"),
"meta": {"area": "coaching"}
},
{
"objectKey": "ui.feature.commcoach.settings",
- "label": "Einstellungen",
+ "label": t("Einstellungen", context="UI"),
"meta": {"area": "settings"}
},
]
@@ -36,7 +38,7 @@ DATA_OBJECTS = [
# ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ──
{
"objectKey": "data.feature.commcoach.CoachingContext",
- "label": "Coaching-Kontext",
+ "label": t("Coaching-Kontext", context="UI"),
"meta": {
"table": "CoachingContext",
"fields": ["id", "title", "category", "status", "lastSessionAt"],
@@ -46,7 +48,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingSession",
- "label": "Coaching-Session",
+ "label": t("Coaching-Session", context="UI"),
"meta": {
"table": "CoachingSession",
"fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
@@ -58,7 +60,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingMessage",
- "label": "Coaching-Nachricht",
+ "label": t("Coaching-Nachricht", context="UI"),
"meta": {
"table": "CoachingMessage",
"fields": ["id", "sessionId", "contextId", "role", "content", "contentType"],
@@ -68,7 +70,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingScore",
- "label": "Coaching-Score",
+ "label": t("Coaching-Score", context="UI"),
"meta": {
"table": "CoachingScore",
"fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"],
@@ -78,7 +80,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingTask",
- "label": "Coaching-Aufgabe",
+ "label": t("Coaching-Aufgabe", context="UI"),
"meta": {
"table": "CoachingTask",
"fields": ["id", "contextId", "title", "status", "priority", "dueDate"],
@@ -89,12 +91,12 @@ DATA_OBJECTS = [
# ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
{
"objectKey": "data.feature.commcoach.userData",
- "label": "Stammdaten",
+ "label": t("Stammdaten", context="UI"),
"meta": {"isGroup": True}
},
{
"objectKey": "data.feature.commcoach.CoachingUserProfile",
- "label": "Benutzerprofil",
+ "label": t("Benutzerprofil", context="UI"),
"meta": {
"table": "CoachingUserProfile",
"group": "data.feature.commcoach.userData",
@@ -103,7 +105,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingPersona",
- "label": "Coaching-Persona",
+ "label": t("Coaching-Persona", context="UI"),
"meta": {
"table": "CoachingPersona",
"group": "data.feature.commcoach.userData",
@@ -112,7 +114,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingBadge",
- "label": "Coaching-Auszeichnung",
+ "label": t("Coaching-Auszeichnung", context="UI"),
"meta": {
"table": "CoachingBadge",
"group": "data.feature.commcoach.userData",
@@ -121,7 +123,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.*",
- "label": "Alle CommCoach-Daten",
+ "label": t("Alle CommCoach-Daten", context="UI"),
"meta": {"wildcard": True}
},
]
@@ -129,27 +131,27 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.commcoach.context.create",
- "label": "Kontext erstellen",
+ "label": t("Kontext erstellen", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.context.archive",
- "label": "Kontext archivieren",
+ "label": t("Kontext archivieren", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.start",
- "label": "Session starten",
+ "label": t("Session starten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.complete",
- "label": "Session abschliessen",
+ "label": t("Session abschliessen", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/sessions/{sessionId}/complete", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.task.manage",
- "label": "Aufgaben verwalten",
+ "label": t("Aufgaben verwalten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"}
},
]
diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py
index a2bff9bc..86530123 100644
--- a/modules/features/graphicalEditor/mainGraphicalEditor.py
+++ b/modules/features/graphicalEditor/mainGraphicalEditor.py
@@ -8,6 +8,8 @@ Minimal bootstrap for feature instance creation. Build from here.
import logging
from typing import Dict, List, Any, Optional
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
FEATURE_CODE = "graphicalEditor"
@@ -21,28 +23,28 @@ REQUIRED_SERVICES = [
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
]
-FEATURE_LABEL = "Grafischer Editor"
+FEATURE_LABEL = t("Grafischer Editor", context="UI")
FEATURE_ICON = "mdi-sitemap"
UI_OBJECTS = [
{
"objectKey": "ui.feature.graphicalEditor.editor",
- "label": "Editor",
+ "label": t("Editor", context="UI"),
"meta": {"area": "editor"}
},
{
"objectKey": "ui.feature.graphicalEditor.workflows",
- "label": "Workflows",
+ "label": t("Workflows", context="UI"),
"meta": {"area": "workflows"}
},
{
"objectKey": "ui.feature.graphicalEditor.templates",
- "label": "Vorlagen",
+ "label": t("Vorlagen", context="UI"),
"meta": {"area": "templates"}
},
{
"objectKey": "ui.feature.graphicalEditor.workflows-tasks",
- "label": "Tasks",
+ "label": t("Tasks", context="UI"),
"meta": {"area": "tasks"}
},
]
@@ -50,17 +52,17 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.graphicalEditor.dashboard",
- "label": "Dashboard aufrufen",
+ "label": t("Dashboard aufrufen", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"}
},
{
"objectKey": "resource.feature.graphicalEditor.node-types",
- "label": "Node-Typen abrufen",
+ "label": t("Node-Typen abrufen", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"}
},
{
"objectKey": "resource.feature.graphicalEditor.execute",
- "label": "Workflow ausführen",
+ "label": t("Workflow ausführen", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}
},
]
diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py
index 2c69fe7b..0fe11aea 100644
--- a/modules/features/neutralization/mainNeutralization.py
+++ b/modules/features/neutralization/mainNeutralization.py
@@ -8,18 +8,20 @@ Handles feature initialization and RBAC catalog registration.
import logging
from typing import Dict, List, Any
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "neutralization"
-FEATURE_LABEL = "Neutralisierung"
+FEATURE_LABEL = t("Neutralisierung", context="UI")
FEATURE_ICON = "mdi-shield-check"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.neutralization.playground",
- "label": "Spielwiese",
+ "label": t("Spielwiese", context="UI"),
"meta": {"area": "playground"}
}
]
@@ -28,17 +30,17 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.neutralization.process.text",
- "label": "Text verarbeiten",
+ "label": t("Text verarbeiten", context="UI"),
"meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.process.files",
- "label": "Dateien verarbeiten",
+ "label": t("Dateien verarbeiten", context="UI"),
"meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.config.update",
- "label": "Konfiguration aktualisieren",
+ "label": t("Konfiguration aktualisieren", context="UI"),
"meta": {"endpoint": "/api/neutralization/config", "method": "PUT"}
},
]
diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py
index 0ae29159..061a54c3 100644
--- a/modules/features/realEstate/mainRealEstate.py
+++ b/modules/features/realEstate/mainRealEstate.py
@@ -8,30 +8,29 @@ This module also handles feature initialization and RBAC catalog registration.
import logging
-# Feature metadata for RBAC catalog
+from modules.shared.i18nRegistry import t
+
FEATURE_CODE = "realestate"
-FEATURE_LABEL = "Immobilien"
+FEATURE_LABEL = t("Immobilien", context="UI")
FEATURE_ICON = "mdi-home-city"
-# UI Objects for RBAC catalog (only map view)
UI_OBJECTS = [
{
"objectKey": "ui.feature.realestate.dashboard",
- "label": "Karte",
+ "label": t("Karte", context="UI"),
"meta": {"area": "dashboard"}
},
]
-# Resource Objects for RBAC catalog
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.realestate.project.create",
- "label": "Projekt erstellen",
+ "label": t("Projekt erstellen", context="UI"),
"meta": {"endpoint": "/api/realestate/project", "method": "POST"}
},
{
"objectKey": "resource.feature.realestate.project.delete",
- "label": "Projekt löschen",
+ "label": t("Projekt löschen", context="UI"),
"meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"}
},
]
diff --git a/modules/features/redmine/mainRedmine.py b/modules/features/redmine/mainRedmine.py
index ba2225b4..919361c1 100644
--- a/modules/features/redmine/mainRedmine.py
+++ b/modules/features/redmine/mainRedmine.py
@@ -33,10 +33,10 @@ UI_OBJECTS: List[Dict[str, Any]] = [
DATA_OBJECTS: List[Dict[str, Any]] = [
- {"objectKey": "data.feature.redmine.config", "label": "Konfiguration", "meta": {"isGroup": True}},
+ {"objectKey": "data.feature.redmine.config", "label": t("Konfiguration", context="UI"), "meta": {"isGroup": True}},
{
"objectKey": "data.feature.redmine.RedmineInstanceConfig",
- "label": "Redmine-Verbindung",
+ "label": t("Redmine-Verbindung", context="UI"),
"meta": {
"table": "RedmineInstanceConfig",
"group": "data.feature.redmine.config",
@@ -45,7 +45,7 @@ DATA_OBJECTS: List[Dict[str, Any]] = [
},
{
"objectKey": "data.feature.redmine.RedmineTicketMirror",
- "label": "Redmine-Tickets (Mirror)",
+ "label": t("Redmine-Tickets (Mirror)", context="UI"),
"meta": {
"table": "RedmineTicketMirror",
"group": "data.feature.redmine.config",
@@ -54,7 +54,7 @@ DATA_OBJECTS: List[Dict[str, Any]] = [
},
{
"objectKey": "data.feature.redmine.RedmineRelationMirror",
- "label": "Redmine-Beziehungen (Mirror)",
+ "label": t("Redmine-Beziehungen (Mirror)", context="UI"),
"meta": {
"table": "RedmineRelationMirror",
"group": "data.feature.redmine.config",
@@ -63,7 +63,7 @@ DATA_OBJECTS: List[Dict[str, Any]] = [
},
{
"objectKey": "data.feature.redmine.*",
- "label": "Alle Redmine-Daten",
+ "label": t("Alle Redmine-Daten", context="UI"),
"meta": {"wildcard": True, "description": "Wildcard for all redmine data tables"},
},
]
@@ -72,62 +72,62 @@ DATA_OBJECTS: List[Dict[str, Any]] = [
RESOURCE_OBJECTS: List[Dict[str, Any]] = [
{
"objectKey": "resource.feature.redmine.tickets.read",
- "label": "Tickets lesen",
+ "label": t("Tickets lesen", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.tickets.create",
- "label": "Tickets erstellen",
+ "label": t("Tickets erstellen", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "POST"},
},
{
"objectKey": "resource.feature.redmine.tickets.update",
- "label": "Tickets bearbeiten",
+ "label": t("Tickets bearbeiten", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "PUT"},
},
{
"objectKey": "resource.feature.redmine.tickets.delete",
- "label": "Tickets loeschen / archivieren",
+ "label": t("Tickets loeschen / archivieren", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "DELETE"},
},
{
"objectKey": "resource.feature.redmine.relations.manage",
- "label": "Beziehungen verwalten",
+ "label": t("Beziehungen verwalten", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}/relations", "method": "ALL"},
},
{
"objectKey": "resource.feature.redmine.stats.read",
- "label": "Statistik einsehen",
+ "label": t("Statistik einsehen", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/stats", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.config.manage",
- "label": "Verbindung verwalten",
+ "label": t("Verbindung verwalten", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/config", "method": "ALL", "admin_only": True},
},
{
"objectKey": "resource.feature.redmine.config.test",
- "label": "Verbindung testen",
+ "label": t("Verbindung testen", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/config/test", "method": "POST", "admin_only": True},
},
{
"objectKey": "resource.feature.redmine.sync.run",
- "label": "Mirror synchronisieren",
+ "label": t("Mirror synchronisieren", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/sync", "method": "POST", "admin_only": True},
},
{
"objectKey": "resource.feature.redmine.sync.status",
- "label": "Sync-Status lesen",
+ "label": t("Sync-Status lesen", context="UI"),
"meta": {"endpoint": "/api/redmine/{instanceId}/sync/status", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.workflows.view",
- "label": "Workflows einsehen",
+ "label": t("Workflows einsehen", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.workflows.execute",
- "label": "Workflows ausfuehren",
+ "label": t("Workflows ausfuehren", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"},
},
]
diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py
index 02d7c333..c2c271e0 100644
--- a/modules/features/teamsbot/mainTeamsbot.py
+++ b/modules/features/teamsbot/mainTeamsbot.py
@@ -8,28 +8,30 @@ Handles feature initialization and RBAC catalog registration.
import logging
from typing import Dict, List, Any
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "teamsbot"
-FEATURE_LABEL = "Teams Bot"
+FEATURE_LABEL = t("Teams Bot", context="UI")
FEATURE_ICON = "mdi-headset"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.teamsbot.dashboard",
- "label": "Dashboard",
+ "label": t("Dashboard", context="UI"),
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.teamsbot.sessions",
- "label": "Sitzungen",
+ "label": t("Sitzungen", context="UI"),
"meta": {"area": "sessions"}
},
{
"objectKey": "ui.feature.teamsbot.settings",
- "label": "Einstellungen",
+ "label": t("Einstellungen", context="UI"),
"meta": {"area": "settings", "admin_only": True}
},
]
@@ -38,7 +40,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [
{
"objectKey": "data.feature.teamsbot.TeamsbotSession",
- "label": "Sitzung",
+ "label": t("Sitzung", context="UI"),
"meta": {
"table": "TeamsbotSession",
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
@@ -48,7 +50,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.teamsbot.TeamsbotTranscript",
- "label": "Transkript",
+ "label": t("Transkript", context="UI"),
"meta": {
"table": "TeamsbotTranscript",
"fields": ["id", "sessionId", "speaker", "text", "timestamp"],
@@ -58,7 +60,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.teamsbot.TeamsbotBotResponse",
- "label": "Bot-Antwort",
+ "label": t("Bot-Antwort", context="UI"),
"meta": {
"table": "TeamsbotBotResponse",
"fields": ["id", "sessionId", "responseText", "detectedIntent"],
@@ -68,7 +70,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.teamsbot.*",
- "label": "Alle Teams Bot Daten",
+ "label": t("Alle Teams Bot Daten", context="UI"),
"meta": {"wildcard": True, "description": "Wildcard for all teamsbot data tables"}
},
]
@@ -77,22 +79,22 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.teamsbot.session.start",
- "label": "Sitzung starten",
+ "label": t("Sitzung starten", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions", "method": "POST"}
},
{
"objectKey": "resource.feature.teamsbot.session.stop",
- "label": "Sitzung beenden",
+ "label": t("Sitzung beenden", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}/stop", "method": "POST"}
},
{
"objectKey": "resource.feature.teamsbot.session.delete",
- "label": "Sitzung löschen",
+ "label": t("Sitzung löschen", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.teamsbot.config.edit",
- "label": "Konfiguration bearbeiten",
+ "label": t("Konfiguration bearbeiten", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
},
]
diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py
index fcf5c8b4..265227a0 100644
--- a/modules/features/trustee/datamodelFeatureTrustee.py
+++ b/modules/features/trustee/datamodelFeatureTrustee.py
@@ -526,7 +526,8 @@ class TrusteePosition(PowerOnModel):
"label": "Buchungsbetrag",
"frontend_type": "number",
"frontend_readonly": False,
- "frontend_required": True
+ "frontend_required": True,
+ "frontend_format": "R:#'###.00",
}
)
originalCurrency: str = Field(
@@ -551,7 +552,8 @@ class TrusteePosition(PowerOnModel):
"label": "Originalbetrag",
"frontend_type": "number",
"frontend_readonly": False,
- "frontend_required": True
+ "frontend_required": True,
+ "frontend_format": "R:#'###.00",
}
)
vatPercentage: float = Field(
@@ -561,7 +563,8 @@ class TrusteePosition(PowerOnModel):
"label": "MwSt-Prozentsatz",
"frontend_type": "number",
"frontend_readonly": False,
- "frontend_required": False
+ "frontend_required": False,
+ "frontend_format": "R:0.00",
}
)
vatAmount: float = Field(
@@ -571,7 +574,8 @@ class TrusteePosition(PowerOnModel):
"label": "MwSt-Betrag",
"frontend_type": "number",
"frontend_readonly": False,
- "frontend_required": False
+ "frontend_required": False,
+ "frontend_format": "R:#'###.00",
}
)
debitAccountNumber: Optional[str] = Field(
@@ -750,7 +754,15 @@ class TrusteeDataJournalEntry(PowerOnModel):
reference: Optional[str] = Field(default=None, description="Booking reference / voucher number", json_schema_extra={"label": "Referenz"})
description: str = Field(default="", description="Booking text", json_schema_extra={"label": "Beschreibung"})
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
- totalAmount: float = Field(default=0.0, description="Total amount of entry", json_schema_extra={"label": "Betrag"})
+ totalAmount: float = Field(
+ default=0.0,
+ description="Total amount of entry",
+ json_schema_extra={
+ "label": "Betrag",
+ # Right-aligned amount with Swiss thousands separator and 2 decimals.
+ "frontend_format": "R:#'###.00",
+ },
+ )
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
@@ -760,8 +772,8 @@ class TrusteeDataJournalLine(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id", json_schema_extra={"label": "Buchung", "fk_target": {"db": "poweron_trustee", "table": "TrusteeDataJournalEntry"}})
accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
- debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll"})
- creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben"})
+ debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll", "frontend_format": "R:#'###.00"})
+ creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben", "frontend_format": "R:#'###.00"})
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
taxCode: Optional[str] = Field(default=None, json_schema_extra={"label": "Steuercode"})
costCenter: Optional[str] = Field(default=None, json_schema_extra={"label": "Kostenstelle"})
@@ -794,10 +806,10 @@ class TrusteeDataAccountBalance(PowerOnModel):
accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
periodYear: int = Field(description="Fiscal year", json_schema_extra={"label": "Jahr"})
periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total", json_schema_extra={"label": "Monat"})
- openingBalance: float = Field(default=0.0, json_schema_extra={"label": "Eröffnungssaldo"})
- debitTotal: float = Field(default=0.0, json_schema_extra={"label": "Soll-Umsatz"})
- creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz"})
- closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo"})
+ openingBalance: float = Field(default=0.0, json_schema_extra={"label": "Eröffnungssaldo", "frontend_format": "R:#'###.00"})
+ debitTotal: float = Field(default=0.0, json_schema_extra={"label": "Soll-Umsatz", "frontend_format": "R:#'###.00"})
+ creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz", "frontend_format": "R:#'###.00"})
+ closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo", "frontend_format": "R:#'###.00"})
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index ac7b871f..0799fa1c 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -8,11 +8,13 @@ Handles feature initialization and RBAC catalog registration.
import logging
from typing import Dict, List, Any
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "trustee"
-FEATURE_LABEL = "Treuhand"
+FEATURE_LABEL = t("Treuhand", context="UI")
FEATURE_ICON = "mdi-briefcase"
# UI Objects for RBAC catalog
@@ -20,7 +22,7 @@ FEATURE_ICON = "mdi-briefcase"
UI_OBJECTS = [
{
"objectKey": "ui.feature.trustee.dashboard",
- "label": "Dashboard",
+ "label": t("Dashboard", context="UI"),
"meta": {"area": "dashboard"}
},
# Note: ui.feature.trustee.positions and .documents removed.
@@ -30,32 +32,32 @@ UI_OBJECTS = [
# remains and continues to gate per-row access.
{
"objectKey": "ui.feature.trustee.data-tables",
- "label": "Daten-Tabellen",
+ "label": t("Daten-Tabellen", context="UI"),
"meta": {"area": "data-tables"}
},
{
"objectKey": "ui.feature.trustee.import-process",
- "label": "Import & Verarbeitung",
+ "label": t("Import & Verarbeitung", context="UI"),
"meta": {"area": "import-process"}
},
{
"objectKey": "ui.feature.trustee.analyse",
- "label": "Analyse & Reporting",
+ "label": t("Analyse & Reporting", context="UI"),
"meta": {"area": "analyse"}
},
{
"objectKey": "ui.feature.trustee.abschluss",
- "label": "Abschluss & Prüfung",
+ "label": t("Abschluss & Prüfung", context="UI"),
"meta": {"area": "abschluss"}
},
{
"objectKey": "ui.feature.trustee.settings",
- "label": "Buchhaltungs-Einstellungen",
+ "label": t("Buchhaltungs-Einstellungen", context="UI"),
"meta": {"area": "settings", "admin_only": True}
},
{
"objectKey": "ui.feature.trustee.instance-roles",
- "label": "Instanz-Rollen & Berechtigungen",
+ "label": t("Instanz-Rollen & Berechtigungen", context="UI"),
"meta": {"area": "admin", "admin_only": True}
},
]
@@ -69,23 +71,23 @@ DATA_OBJECTS = [
# ── Categorical Groups (UDB folders) ─────────────────────────────────────
{
"objectKey": "data.feature.trustee.localData",
- "label": "Lokale Daten",
+ "label": t("Lokale Daten", context="UI"),
"meta": {"isGroup": True}
},
{
"objectKey": "data.feature.trustee.config",
- "label": "Konfiguration",
+ "label": t("Konfiguration", context="UI"),
"meta": {"isGroup": True}
},
{
"objectKey": "data.feature.trustee.accountingData",
- "label": "Daten aus Buchhaltungssystem",
+ "label": t("Daten aus Buchhaltungssystem", context="UI"),
"meta": {"isGroup": True}
},
# ── Lokale Daten ─────────────────────────────────────────────────────────
{
"objectKey": "data.feature.trustee.TrusteePosition",
- "label": "Position",
+ "label": t("Position", context="UI"),
"meta": {
"table": "TrusteePosition",
"group": "data.feature.trustee.localData",
@@ -94,7 +96,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeDocument",
- "label": "Dokument",
+ "label": t("Dokument", context="UI"),
"meta": {
"table": "TrusteeDocument",
"group": "data.feature.trustee.localData",
@@ -104,7 +106,7 @@ DATA_OBJECTS = [
# ── Konfiguration ────────────────────────────────────────────────────────
{
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
- "label": "Buchhaltungs-Verbindung",
+ "label": t("Buchhaltungs-Verbindung", context="UI"),
"meta": {
"table": "TrusteeAccountingConfig",
"group": "data.feature.trustee.config",
@@ -113,7 +115,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
- "label": "Sync-Protokoll",
+ "label": t("Sync-Protokoll", context="UI"),
"meta": {
"table": "TrusteeAccountingSync",
"group": "data.feature.trustee.config",
@@ -123,7 +125,7 @@ DATA_OBJECTS = [
# ── Daten aus Buchhaltungssystem ─────────────────────────────────────────
{
"objectKey": "data.feature.trustee.TrusteeDataAccount",
- "label": "Kontenplan",
+ "label": t("Kontenplan", context="UI"),
"meta": {
"table": "TrusteeDataAccount",
"group": "data.feature.trustee.accountingData",
@@ -132,7 +134,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeDataJournalEntry",
- "label": "Buchungen",
+ "label": t("Buchungen", context="UI"),
"meta": {
"table": "TrusteeDataJournalEntry",
"group": "data.feature.trustee.accountingData",
@@ -141,7 +143,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeDataJournalLine",
- "label": "Buchungszeilen",
+ "label": t("Buchungszeilen", context="UI"),
"meta": {
"table": "TrusteeDataJournalLine",
"group": "data.feature.trustee.accountingData",
@@ -150,7 +152,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeDataContact",
- "label": "Kontakte",
+ "label": t("Kontakte", context="UI"),
"meta": {
"table": "TrusteeDataContact",
"group": "data.feature.trustee.accountingData",
@@ -159,7 +161,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.TrusteeDataAccountBalance",
- "label": "Kontosalden",
+ "label": t("Kontosalden", context="UI"),
"meta": {
"table": "TrusteeDataAccountBalance",
"group": "data.feature.trustee.accountingData",
@@ -168,7 +170,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.trustee.*",
- "label": "Alle Treuhand-Daten",
+ "label": t("Alle Treuhand-Daten", context="UI"),
"meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"}
},
]
@@ -178,67 +180,67 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.trustee.documents.create",
- "label": "Dokument hochladen",
+ "label": t("Dokument hochladen", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.documents.update",
- "label": "Dokument aktualisieren",
+ "label": t("Dokument aktualisieren", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.trustee.documents.delete",
- "label": "Dokument löschen",
+ "label": t("Dokument löschen", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.trustee.positions.create",
- "label": "Position erstellen",
+ "label": t("Position erstellen", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.positions.update",
- "label": "Position aktualisieren",
+ "label": t("Position aktualisieren", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.trustee.positions.delete",
- "label": "Position löschen",
+ "label": t("Position löschen", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.trustee.instance-roles.manage",
- "label": "Instanz-Rollen verwalten",
+ "label": t("Instanz-Rollen verwalten", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True}
},
{
"objectKey": "resource.feature.trustee.accounting.manage",
- "label": "Buchhaltungs-Integration verwalten",
+ "label": t("Buchhaltungs-Integration verwalten", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/accounting/config", "method": "ALL", "admin_only": True}
},
{
"objectKey": "resource.feature.trustee.accounting.sync",
- "label": "Buchhaltung synchronisieren",
+ "label": t("Buchhaltung synchronisieren", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.accounting.view",
- "label": "Sync-Status einsehen",
+ "label": t("Sync-Status einsehen", context="UI"),
"meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync-status", "method": "GET"}
},
{
"objectKey": "resource.feature.trustee.workflows.view",
- "label": "Workflows einsehen",
+ "label": t("Workflows einsehen", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"}
},
{
"objectKey": "resource.feature.trustee.workflows.execute",
- "label": "Workflows ausführen",
+ "label": t("Workflows ausführen", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.workflows.manage",
- "label": "Workflows verwalten",
+ "label": t("Workflows verwalten", context="UI"),
"meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "ALL", "admin_only": True}
},
]
@@ -256,7 +258,7 @@ QUICK_ACTION_CATEGORIES = [
QUICK_ACTIONS = [
{
"id": "trustee-process-receipts",
- "label": "Belege verarbeiten",
+ "label": t("Belege verarbeiten", context="UI"),
"description": "Belege aus SharePoint importieren, klassifizieren und verbuchen",
"icon": "mdi-file-document-check-outline",
"color": "#4CAF50",
@@ -268,7 +270,7 @@ QUICK_ACTIONS = [
},
{
"id": "trustee-upload-receipt",
- "label": "Beleg hochladen",
+ "label": t("Beleg hochladen", context="UI"),
"description": "Beleg scannen oder als Datei hochladen",
"icon": "mdi-camera-document-outline",
"color": "#607D8B",
@@ -280,7 +282,7 @@ QUICK_ACTIONS = [
},
{
"id": "trustee-sync-accounting",
- "label": "Daten einlesen",
+ "label": t("Daten einlesen", context="UI"),
"description": "Buchhaltungsdaten aus dem externen System aktualisieren",
"icon": "mdi-sync",
"color": "#FF9800",
@@ -292,7 +294,7 @@ QUICK_ACTIONS = [
},
{
"id": "trustee-budget-comparison",
- "label": "Budget-Vergleich",
+ "label": t("Budget-Vergleich", context="UI"),
"description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel",
"icon": "mdi-chart-bar",
"color": "#2196F3",
@@ -304,7 +306,7 @@ QUICK_ACTIONS = [
},
{
"id": "trustee-kpi-dashboard",
- "label": "KPI-Dashboard",
+ "label": t("KPI-Dashboard", context="UI"),
"description": "Kennzahlen berechnen und visualisieren",
"icon": "mdi-view-dashboard-outline",
"color": "#9C27B0",
@@ -316,7 +318,7 @@ QUICK_ACTIONS = [
},
{
"id": "trustee-cashflow",
- "label": "Cashflow-Rechnung",
+ "label": t("Cashflow-Rechnung", context="UI"),
"description": "Cashflow berechnen und analysieren",
"icon": "mdi-cash-multiple",
"color": "#009688",
@@ -328,7 +330,7 @@ QUICK_ACTIONS = [
},
{
"id": "trustee-forecast",
- "label": "Prognose erstellen",
+ "label": t("Prognose erstellen", context="UI"),
"description": "Trend-Analyse und Prognose der nächsten Monate",
"icon": "mdi-chart-timeline-variant",
"color": "#E91E63",
@@ -340,7 +342,7 @@ QUICK_ACTIONS = [
},
{
"id": "trustee-year-end-check",
- "label": "Jahresabschluss prüfen",
+ "label": t("Jahresabschluss prüfen", context="UI"),
"description": "Automatische Prüfungen für den Jahresabschluss",
"icon": "mdi-clipboard-check-outline",
"color": "#795548",
@@ -383,7 +385,7 @@ def _buildAnalysisWorkflowGraph(prompt: str) -> Dict[str, Any]:
TEMPLATE_WORKFLOWS = [
{
"id": "trustee-receipt-import",
- "label": "Beleg-Import Pipeline",
+ "label": t("Beleg-Import Pipeline", context="UI"),
"description": "Belege extrahieren, verarbeiten und in Buchhaltung synchronisieren",
"tags": ["feature:trustee", "template:trustee-receipt-import"],
"graph": {
@@ -405,7 +407,7 @@ TEMPLATE_WORKFLOWS = [
},
{
"id": "trustee-sync-accounting",
- "label": "Buchhaltung synchronisieren",
+ "label": t("Buchhaltung synchronisieren", context="UI"),
"description": "Buchhaltungsdaten aus dem externen System aktualisieren",
"tags": ["feature:trustee", "template:trustee-sync-accounting"],
"graph": {
@@ -421,7 +423,7 @@ TEMPLATE_WORKFLOWS = [
},
{
"id": "trustee-budget-comparison",
- "label": "Budget-Vergleich",
+ "label": t("Budget-Vergleich", context="UI"),
"description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel",
"tags": ["feature:trustee", "template:trustee-budget-comparison"],
"graph": {
@@ -454,7 +456,7 @@ TEMPLATE_WORKFLOWS = [
},
{
"id": "trustee-kpi-dashboard",
- "label": "KPI-Dashboard",
+ "label": t("KPI-Dashboard", context="UI"),
"description": "Kennzahlen berechnen und visualisieren",
"tags": ["feature:trustee", "template:trustee-kpi-dashboard"],
"graph": _buildAnalysisWorkflowGraph(
@@ -471,7 +473,7 @@ TEMPLATE_WORKFLOWS = [
},
{
"id": "trustee-cashflow",
- "label": "Cashflow-Rechnung",
+ "label": t("Cashflow-Rechnung", context="UI"),
"description": "Cashflow berechnen und analysieren",
"tags": ["feature:trustee", "template:trustee-cashflow"],
"graph": _buildAnalysisWorkflowGraph(
@@ -485,7 +487,7 @@ TEMPLATE_WORKFLOWS = [
},
{
"id": "trustee-forecast",
- "label": "Prognose erstellen",
+ "label": t("Prognose erstellen", context="UI"),
"description": "Trend-Analyse und Prognose der nächsten Monate",
"tags": ["feature:trustee", "template:trustee-forecast"],
"graph": _buildAnalysisWorkflowGraph(
@@ -500,7 +502,7 @@ TEMPLATE_WORKFLOWS = [
},
{
"id": "trustee-year-end-check",
- "label": "Jahresabschluss prüfen",
+ "label": t("Jahresabschluss prüfen", context="UI"),
"description": "Automatische Prüfungen für den Jahresabschluss",
"tags": ["feature:trustee", "template:trustee-year-end-check"],
"graph": _buildAnalysisWorkflowGraph(
diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py
index bb501f21..24307b45 100644
--- a/modules/features/workspace/mainWorkspace.py
+++ b/modules/features/workspace/mainWorkspace.py
@@ -9,31 +9,33 @@ Unified AI Workspace feature.
import logging
from typing import Dict, List, Any
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
FEATURE_CODE = "workspace"
-FEATURE_LABEL = "AI Workspace"
+FEATURE_LABEL = t("AI Workspace", context="UI")
FEATURE_ICON = "mdi-brain"
UI_OBJECTS = [
{
"objectKey": "ui.feature.workspace.dashboard",
- "label": "Dashboard",
+ "label": t("Dashboard", context="UI"),
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.workspace.editor",
- "label": "Editor",
+ "label": t("Editor", context="UI"),
"meta": {"area": "editor"}
},
{
"objectKey": "ui.feature.workspace.settings",
- "label": "Einstellungen",
+ "label": t("Einstellungen", context="UI"),
"meta": {"area": "settings"}
},
{
"objectKey": "ui.feature.workspace.rag-insights",
- "label": "Wissens-Insights",
+ "label": t("Wissens-Insights", context="UI"),
"meta": {"area": "rag-insights"},
},
]
@@ -41,37 +43,37 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.workspace.start",
- "label": "Agent starten",
+ "label": t("Agent starten", context="UI"),
"meta": {"endpoint": "/api/workspace/{instanceId}/start/stream", "method": "POST"}
},
{
"objectKey": "resource.feature.workspace.stop",
- "label": "Agent stoppen",
+ "label": t("Agent stoppen", context="UI"),
"meta": {"endpoint": "/api/workspace/{instanceId}/{workflowId}/stop", "method": "POST"}
},
{
"objectKey": "resource.feature.workspace.files",
- "label": "Dateien verwalten",
+ "label": t("Dateien verwalten", context="UI"),
"meta": {"endpoint": "/api/workspace/{instanceId}/files", "method": "GET"}
},
{
"objectKey": "resource.feature.workspace.folders",
- "label": "Ordner verwalten",
+ "label": t("Ordner verwalten", context="UI"),
"meta": {"endpoint": "/api/workspace/{instanceId}/folders", "method": "GET"}
},
{
"objectKey": "resource.feature.workspace.datasources",
- "label": "Datenquellen",
+ "label": t("Datenquellen", context="UI"),
"meta": {"endpoint": "/api/workspace/{instanceId}/datasources", "method": "GET"}
},
{
"objectKey": "resource.feature.workspace.voice",
- "label": "Spracheingabe/-ausgabe",
+ "label": t("Spracheingabe/-ausgabe", context="UI"),
"meta": {"endpoint": "/api/workspace/{instanceId}/voice/*", "method": "POST"}
},
{
"objectKey": "resource.feature.workspace.edits",
- "label": "Datei-Aenderungen pruefen",
+ "label": t("Datei-Aenderungen pruefen", context="UI"),
"meta": {"endpoint": "/api/workspace/{instanceId}/edit/*", "method": "POST"}
},
]
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 5a556616..e9d78e55 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -1906,6 +1906,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
"resource.store.workspace",
"resource.store.commcoach",
"resource.store.trustee",
+ "resource.store.graphicalEditor",
]
storeRules = []
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
index fc071ad1..a369530b 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py
@@ -235,11 +235,15 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
registry.register(
"browseDataSource", _browseDataSource,
description=(
- "Browse files and folders in a data source. Accepts either:\n"
+ "Browse files, folders, or emails in a data source. Accepts either:\n"
"- dataSourceId (for attached data sources shown in the prompt), OR\n"
"- connectionId + service (for direct connection access via listConnections).\n"
- "Default page size is connector-specific (~100 entries). Use the `limit` parameter "
- "to request more (e.g. when the user explicitly asks for ALL items in a folder)."
+ "\n"
+ "DEFAULT BEHAVIOUR: omit `limit` to get the connector's full default page. "
+ "For mail folders (Outlook/Gmail) the default returns up to 100 newest "
+ "messages -- DO NOT pass a smaller limit just to be safe; users almost "
+ "always want the full default page or explicitly more. Only set `limit` "
+ "when the user asks for a specific number (e.g. 'show me the latest 5 mails')."
),
parameters={
"type": "object",
@@ -253,8 +257,11 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
"limit": {
"type": "integer",
"description": (
- "Maximum number of entries to return (max 1000 for mail, "
- "connector-specific elsewhere). Omit for the connector's default."
+ "OPTIONAL. Maximum number of entries to return. OMIT this "
+ "parameter to get the connector default (100 for mail, "
+ "connector-specific elsewhere). Only set this when the user "
+ "explicitly asks for a specific count, OR when the previous "
+ "browse hit the default and you need MORE (then set up to 1000)."
),
"minimum": 1,
"maximum": 1000,
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py
new file mode 100644
index 00000000..a49403cd
--- /dev/null
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py
@@ -0,0 +1,401 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Email management agent tools (reply, forward, move, delete, flag, folder ops).
+
+Complements ``_connectionTools.sendMail`` -- which only knows how to compose a
+brand-new email -- with the full Outlook mail-management surface the agent
+needs to actually answer existing threads and curate the inbox.
+
+All tools resolve their target Outlook adapter via
+``ConnectorResolver.resolveService(connectionId, "outlook")`` and require an
+``msft`` UserConnection. They live in the ``email`` toolbox.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult
+from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
+from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
+ _buildResolverDbFromServices,
+)
+
+logger = logging.getLogger(__name__)
+
+
+async def _resolveOutlookAdapter(services, connectionId: str):
+ """Resolve an Outlook adapter for ``connectionId``.
+
+ Centralised because every tool here needs the exact same boilerplate.
+ Raises ``ValueError`` if the connection cannot be resolved so callers can
+ return a clean ToolResult error.
+ """
+ from modules.connectors.connectorResolver import ConnectorResolver
+ resolver = ConnectorResolver(
+ services.getService("security"),
+ _buildResolverDbFromServices(services),
+ )
+ adapter = await resolver.resolveService(connectionId, "outlook")
+ if adapter is None:
+ raise ValueError(f"Could not resolve Outlook adapter for connection '{connectionId}'")
+ return adapter
+
+
+def _extractMessageId(args: Dict[str, Any]) -> str:
+ """Pull a Graph message ID from args.
+
+ Accepts both ``messageId`` (preferred) and ``filePath`` -- the latter is
+ what ``browseDataSource`` returns in its ``path:`` field for mail entries
+ (``//``). The LLM often passes that verbatim.
+ """
+ messageId = (args.get("messageId") or "").strip()
+ if messageId:
+ return messageId
+ raw = (args.get("filePath") or args.get("path") or "").strip()
+ if raw:
+ return raw.strip("/").split("/")[-1]
+ return ""
+
+
+def _registerEmailTools(registry: ToolRegistry, services):
+ """Register Outlook reply/forward/move/delete/flag tools on ``registry``."""
+
+ # ------------------------------------------------------------------
+ # Reply / Reply-All / Forward
+ # ------------------------------------------------------------------
+
+ async def _replyToMail(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ comment = args.get("comment") or args.get("body") or ""
+ replyAll = bool(args.get("replyAll", False))
+ draft = bool(args.get("draft", False))
+ messageId = _extractMessageId(args)
+ if not connectionId or not messageId:
+ return ToolResult(toolCallId="", toolName="replyToMail", success=False,
+ error="connectionId and messageId are required (messageId or filePath from browseDataSource)")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ if draft:
+ if not hasattr(adapter, "createReplyDraft"):
+ return ToolResult(toolCallId="", toolName="replyToMail", success=False, error="Adapter does not support reply drafts")
+ result = await adapter.createReplyDraft(messageId, comment=comment, replyAll=replyAll)
+ else:
+ if not hasattr(adapter, "replyToMail"):
+ return ToolResult(toolCallId="", toolName="replyToMail", success=False, error="Adapter does not support replies")
+ result = await adapter.replyToMail(messageId, comment=comment, replyAll=replyAll)
+ if "error" in result:
+ return ToolResult(toolCallId="", toolName="replyToMail", success=False, error=str(result["error"]))
+ return ToolResult(toolCallId="", toolName="replyToMail", success=True, data=str(result))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="replyToMail", success=False, error=str(e))
+
+ async def _forwardMail(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ comment = args.get("comment") or args.get("body") or ""
+ to = args.get("to") or []
+ draft = bool(args.get("draft", False))
+ messageId = _extractMessageId(args)
+ if not connectionId or not messageId:
+ return ToolResult(toolCallId="", toolName="forwardMail", success=False,
+ error="connectionId and messageId are required")
+ if not draft and not to:
+ return ToolResult(toolCallId="", toolName="forwardMail", success=False,
+ error="`to` recipients are required when not creating a draft")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ if draft:
+ result = await adapter.createForwardDraft(messageId, to=to or None, comment=comment)
+ else:
+ result = await adapter.forwardMail(messageId, to=to, comment=comment)
+ if "error" in result:
+ return ToolResult(toolCallId="", toolName="forwardMail", success=False, error=str(result["error"]))
+ return ToolResult(toolCallId="", toolName="forwardMail", success=True, data=str(result))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="forwardMail", success=False, error=str(e))
+
+ # ------------------------------------------------------------------
+ # Move / Copy / Delete / Archive
+ # ------------------------------------------------------------------
+
+ async def _moveMail(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ destination = (args.get("destinationFolder") or args.get("destination") or "").strip()
+ copyMode = bool(args.get("copy", False))
+ messageId = _extractMessageId(args)
+ if not connectionId or not messageId or not destination:
+ return ToolResult(toolCallId="", toolName="moveMail", success=False,
+ error="connectionId, messageId, and destinationFolder are required")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ method = adapter.copyMail if copyMode else adapter.moveMail
+ result = await method(messageId, destination)
+ if "error" in result:
+ return ToolResult(toolCallId="", toolName="moveMail", success=False, error=str(result["error"]))
+ return ToolResult(toolCallId="", toolName="moveMail", success=True, data=str(result))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="moveMail", success=False, error=str(e))
+
+ async def _deleteMail(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ hardDelete = bool(args.get("hardDelete", False))
+ # Hard-delete is irreversible -- require an explicit confirmation flag
+ # so a single misroutet LLM call cannot wipe a mailbox. The default
+ # behaviour (move to Deleted Items) is recoverable from Outlook UI.
+ confirmed = bool(args.get("confirmedHardDelete", False))
+ messageId = _extractMessageId(args)
+ if not connectionId or not messageId:
+ return ToolResult(toolCallId="", toolName="deleteMail", success=False,
+ error="connectionId and messageId are required")
+ if hardDelete and not confirmed:
+ return ToolResult(toolCallId="", toolName="deleteMail", success=False,
+ error="Hard-delete requires confirmedHardDelete=true (irreversible). Default deleteMail (without hardDelete) moves the mail to Deleted Items where it can still be recovered.")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ result = await adapter.deleteMail(messageId, hardDelete=hardDelete)
+ if "error" in result:
+ return ToolResult(toolCallId="", toolName="deleteMail", success=False, error=str(result["error"]))
+ return ToolResult(toolCallId="", toolName="deleteMail", success=True, data=str(result))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="deleteMail", success=False, error=str(e))
+
+ async def _archiveMail(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ messageId = _extractMessageId(args)
+ if not connectionId or not messageId:
+ return ToolResult(toolCallId="", toolName="archiveMail", success=False,
+ error="connectionId and messageId are required")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ result = await adapter.archiveMail(messageId)
+ if "error" in result:
+ return ToolResult(toolCallId="", toolName="archiveMail", success=False, error=str(result["error"]))
+ return ToolResult(toolCallId="", toolName="archiveMail", success=True, data=str(result))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="archiveMail", success=False, error=str(e))
+
+ # ------------------------------------------------------------------
+ # Read-state / Flag
+ # ------------------------------------------------------------------
+
+ async def _setMailReadState(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ isRead = bool(args.get("isRead", True))
+ messageId = _extractMessageId(args)
+ if not connectionId or not messageId:
+ return ToolResult(toolCallId="", toolName="setMailReadState", success=False,
+ error="connectionId and messageId are required")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ method = adapter.markMailAsRead if isRead else adapter.markMailAsUnread
+ result = await method(messageId)
+ if "error" in result:
+ return ToolResult(toolCallId="", toolName="setMailReadState", success=False, error=str(result["error"]))
+ return ToolResult(toolCallId="", toolName="setMailReadState", success=True, data=str(result))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="setMailReadState", success=False, error=str(e))
+
+ async def _flagMail(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ flagStatus = (args.get("flagStatus") or "flagged").strip()
+ messageId = _extractMessageId(args)
+ if not connectionId or not messageId:
+ return ToolResult(toolCallId="", toolName="flagMail", success=False,
+ error="connectionId and messageId are required")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ result = await adapter.flagMail(messageId, flagStatus=flagStatus)
+ if "error" in result:
+ return ToolResult(toolCallId="", toolName="flagMail", success=False, error=str(result["error"]))
+ return ToolResult(toolCallId="", toolName="flagMail", success=True, data=str(result))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="flagMail", success=False, error=str(e))
+
+ # ------------------------------------------------------------------
+ # Folder discovery
+ # ------------------------------------------------------------------
+
+ async def _listMailFolders(args: Dict[str, Any], context: Dict[str, Any]):
+ connectionId = (args.get("connectionId") or "").strip()
+ if not connectionId:
+ return ToolResult(toolCallId="", toolName="listMailFolders", success=False,
+ error="connectionId is required")
+ try:
+ adapter = await _resolveOutlookAdapter(services, connectionId)
+ if not hasattr(adapter, "listMailFolders"):
+ return ToolResult(toolCallId="", toolName="listMailFolders", success=False, error="Adapter does not support listMailFolders")
+ folders = await adapter.listMailFolders()
+ if not folders:
+ return ToolResult(toolCallId="", toolName="listMailFolders", success=True, data="No mail folders found.")
+ lines = [
+ f"- {f['displayName']} id={f['id']} total={f['totalItemCount']} unread={f['unreadItemCount']}"
+ for f in folders
+ ]
+ return ToolResult(toolCallId="", toolName="listMailFolders", success=True, data="\n".join(lines))
+ except Exception as e:
+ return ToolResult(toolCallId="", toolName="listMailFolders", success=False, error=str(e))
+
+ # ------------------------------------------------------------------
+ # Tool registration
+ # ------------------------------------------------------------------
+
+ _baseConnParam = {
+ "connectionId": {"type": "string", "description": "UserConnection UUID for the Outlook account (from listConnections)"},
+ }
+ _baseMessageParam = {
+ "messageId": {
+ "type": "string",
+ "description": "Graph message ID of the target mail. Pass either this OR filePath -- both accepted.",
+ },
+ "filePath": {
+ "type": "string",
+ "description": "Mail path as returned by browseDataSource (e.g. '//'). Convenience alias for messageId.",
+ },
+ }
+
+ registry.register(
+ "replyToMail", _replyToMail,
+ description=(
+ "Reply (or reply-all) to an existing email. Preserves the conversation thread "
+ "and the 'AW:' prefix in Outlook -- USE THIS instead of sendMail when the user "
+ "asks you to answer an existing message. Set draft=true to create a draft in the "
+ "Drafts folder for review instead of sending immediately."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ **_baseMessageParam,
+ "comment": {"type": "string", "description": "Reply body text (HTML supported by Outlook)"},
+ "replyAll": {"type": "boolean", "description": "If true, reply to ALL recipients of the original mail"},
+ "draft": {"type": "boolean", "description": "If true, save as draft in Drafts folder instead of sending"},
+ },
+ "required": ["connectionId", "comment"],
+ },
+ readOnly=False,
+ )
+
+ registry.register(
+ "forwardMail", _forwardMail,
+ description=(
+ "Forward an existing email to new recipients. Set draft=true to create a draft "
+ "(in which case `to` may be omitted and filled in later by the user)."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ **_baseMessageParam,
+ "to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"},
+ "comment": {"type": "string", "description": "Optional message to prepend to the forwarded mail"},
+ "draft": {"type": "boolean", "description": "If true, save as draft instead of sending"},
+ },
+ "required": ["connectionId"],
+ },
+ readOnly=False,
+ )
+
+ registry.register(
+ "moveMail", _moveMail,
+ description=(
+ "Move (or copy) an existing email into another mail folder. The destination can "
+ "be a well-known folder name ('inbox', 'archive', 'deleteditems', 'sentitems', "
+ "'drafts', 'junkemail'), a localized display name ('Posteingang', 'Archiv', "
+ "'Papierkorb'), or a Graph folder ID from listMailFolders. Set copy=true to copy "
+ "instead of moving."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ **_baseMessageParam,
+ "destinationFolder": {"type": "string", "description": "Target folder: well-known name, displayName, or folder id"},
+ "copy": {"type": "boolean", "description": "If true, copy instead of moving (original stays in place)"},
+ },
+ "required": ["connectionId", "destinationFolder"],
+ },
+ readOnly=False,
+ )
+
+ registry.register(
+ "deleteMail", _deleteMail,
+ description=(
+ "Delete an email. By default the message is moved to 'Deleted Items' (the same "
+ "as pressing Delete in Outlook -- recoverable). Set hardDelete=true together with "
+ "confirmedHardDelete=true to permanently and irrecoverably remove it -- only do "
+ "this when the user has explicitly asked for permanent deletion."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ **_baseMessageParam,
+ "hardDelete": {"type": "boolean", "description": "Permanently delete instead of moving to Deleted Items"},
+ "confirmedHardDelete": {"type": "boolean", "description": "Required confirmation flag when hardDelete=true"},
+ },
+ "required": ["connectionId"],
+ },
+ readOnly=False,
+ )
+
+ registry.register(
+ "archiveMail", _archiveMail,
+ description="Move an email to the Archive folder. Convenience wrapper around moveMail with destinationFolder='archive'.",
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ **_baseMessageParam,
+ },
+ "required": ["connectionId"],
+ },
+ readOnly=False,
+ )
+
+ registry.register(
+ "setMailReadState", _setMailReadState,
+ description="Mark an email as read (isRead=true) or unread (isRead=false).",
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ **_baseMessageParam,
+ "isRead": {"type": "boolean", "description": "true => mark as read, false => mark as unread"},
+ },
+ "required": ["connectionId"],
+ },
+ readOnly=False,
+ )
+
+ registry.register(
+ "flagMail", _flagMail,
+ description="Set or clear the follow-up flag on an email. flagStatus = 'flagged' (default), 'complete', or 'notFlagged'.",
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ **_baseMessageParam,
+ "flagStatus": {"type": "string", "enum": ["flagged", "complete", "notFlagged"], "description": "Flag state"},
+ },
+ "required": ["connectionId"],
+ },
+ readOnly=False,
+ )
+
+ registry.register(
+ "listMailFolders", _listMailFolders,
+ description=(
+ "List all mail folders in the connected Outlook mailbox with id, displayName, "
+ "totalItemCount and unreadItemCount. Use this BEFORE moveMail when the user "
+ "names a non-standard folder so you can resolve the correct folder ID."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ **_baseConnParam,
+ },
+ "required": ["connectionId"],
+ },
+ readOnly=True,
+ )
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py
index 234da7d8..f740f276 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py
@@ -8,6 +8,7 @@ from modules.serviceCenter.services.serviceAgent.coreTools._workspaceTools impor
from modules.serviceCenter.services.serviceAgent.coreTools._connectionTools import _registerConnectionTools
from modules.serviceCenter.services.serviceAgent.coreTools._dataSourceTools import _registerDataSourceTools
from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import _registerDocumentTools
+from modules.serviceCenter.services.serviceAgent.coreTools._emailTools import _registerEmailTools
from modules.serviceCenter.services.serviceAgent.coreTools._mediaTools import _registerMediaTools
from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import _registerFeatureSubAgentTools
from modules.serviceCenter.services.serviceAgent.coreTools._crossWorkflowTools import _registerCrossWorkflowTools
@@ -22,6 +23,7 @@ def registerCoreTools(registry: ToolRegistry, services):
_registerConnectionTools(registry, services)
_registerDataSourceTools(registry, services)
_registerDocumentTools(registry, services)
+ _registerEmailTools(registry, services)
_registerMediaTools(registry, services)
_registerFeatureSubAgentTools(registry, services)
_registerCrossWorkflowTools(registry, services)
diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py
index 71c75eb5..dd8c80ec 100644
--- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py
+++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py
@@ -148,11 +148,25 @@ def _registerDefaultToolboxes() -> None:
ToolboxDefinition(
id="email",
label="Email",
- description="Send emails or save as draft via Outlook (supports HTML body and file attachments). Use sendMail with draft=true for drafts.",
+ description=(
+ "Outlook mail management: send/draft new mails, reply or forward existing "
+ "messages (preserves the conversation thread), move/copy/delete/archive "
+ "mails, mark as read/unread, set follow-up flags, and list mail folders. "
+ "Use replyToMail (NOT sendMail) when answering an existing message so the "
+ "Outlook thread stays intact."
+ ),
requiresConnection="msft",
isDefault=False,
tools=[
"sendMail",
+ "replyToMail",
+ "forwardMail",
+ "moveMail",
+ "deleteMail",
+ "archiveMail",
+ "setMailReadState",
+ "flagMail",
+ "listMailFolders",
],
),
ToolboxDefinition(
diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py
index 0e4b2cde..d6228854 100644
--- a/modules/shared/attributeUtils.py
+++ b/modules/shared/attributeUtils.py
@@ -36,6 +36,16 @@ class AttributeDefinition(BaseModel):
fkSource: Optional[str] = None
fkDisplayField: Optional[str] = None
fkModel: Optional[str] = None # DB table / Pydantic model name for server-side FK sort (JOIN)
+ # ------------------------------------------------------------------
+ # Render hints for the frontend FormGenerator / Tables.
+ # ``frontendFormat`` is an Excel-style format string the FE applies to numeric,
+ # int, binary or unit values (e.g. "R:#'###.00", "L:0.000", "M:b", "R:@CHF@ #'###.00").
+ # ``frontendFormatLabels`` carries i18n-resolved string tokens referenced by the
+ # format (e.g. boolean labels ["Ja", "-", "Nein"]). They are pre-translated server
+ # side so the FE can render them as-is without another i18n round-trip.
+ # ------------------------------------------------------------------
+ frontendFormat: Optional[str] = None
+ frontendFormatLabels: Optional[List[str]] = None
def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
@@ -138,6 +148,11 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
frontend_fk_source = None # FK dropdown source (e.g., "/api/users/")
frontend_fk_display_field = None # Which field of the FK target to display (e.g., "username", "name")
fk_model = None # Same as fk_model in json_schema_extra — backend JOIN target table name
+ # Render hints (cf. AttributeDefinition.frontendFormat / frontendFormatLabels).
+ # Optional Excel-like format string ("R:#'###.00") plus translatable label tokens
+ # for boolean/categorical render (e.g. ["Ja","-","Nein"] resolved via @i18nModel).
+ frontend_format = None
+ frontend_format_labels = None
if field_info:
# Try direct attributes first (though these won't exist for custom kwargs)
@@ -196,6 +211,18 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
frontend_fk_display_field = json_extra.get("frontend_fk_display_field")
if "fk_model" in json_extra:
fk_model = json_extra.get("fk_model")
+ if frontend_format is None and "frontend_format" in json_extra:
+ frontend_format = json_extra.get("frontend_format")
+ if frontend_format_labels is None and "frontend_format_labels" in json_extra:
+ frontend_format_labels = json_extra.get("frontend_format_labels")
+
+ # Render hints can also come via FieldInfo.extra (older Pydantic kwargs path)
+ if hasattr(field_info, "extra") and isinstance(field_info.extra, dict):
+ extra_dict = field_info.extra
+ if frontend_format is None and "frontend_format" in extra_dict:
+ frontend_format = extra_dict.get("frontend_format")
+ if frontend_format_labels is None and "frontend_format_labels" in extra_dict:
+ frontend_format_labels = extra_dict.get("frontend_format_labels")
# Use frontend type if available, otherwise detect from Python type
if frontend_type:
@@ -274,6 +301,18 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
if fk_model:
attr_def["fkModel"] = fk_model
+ # Render hints (Excel-like format string + i18n-resolved label tokens).
+ # Labels are resolved server-side via resolveText() so the FE renders them
+ # verbatim (no double-translation, no missing-key brackets in the table).
+ if frontend_format:
+ attr_def["frontendFormat"] = frontend_format
+ if frontend_format_labels and isinstance(frontend_format_labels, list):
+ from modules.shared.i18nRegistry import resolveText
+ attr_def["frontendFormatLabels"] = [
+ resolveText(lbl) if isinstance(lbl, (str, dict)) else str(lbl)
+ for lbl in frontend_format_labels
+ ]
+
attributes.append(attr_def)
return {"model": model_label, "attributes": attributes}
diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py
index 4b942bd2..d6bbd1e2 100644
--- a/modules/shared/i18nRegistry.py
+++ b/modules/shared/i18nRegistry.py
@@ -214,6 +214,18 @@ def i18nModel(modelLabel: str, aiContext: str = ""):
else:
attributes[fieldName] = fieldName
+ # Render-hint label tokens (frontend_format_labels) are user-visible
+ # strings that appear in tables/forms (e.g. boolean labels
+ # ["Ja","-","Nein"], unit suffixes ["KB","MB","GB",...]). Register
+ # each non-empty token under a per-field context so they appear in
+ # the xx base set and get AI-translated like every other UI string.
+ formatLabels = extra.get("frontend_format_labels")
+ if isinstance(formatLabels, list):
+ fmtCtx = f"table.{className}.{fieldName}.format"
+ for token in formatLabels:
+ if isinstance(token, str) and token.strip():
+ t(token, fmtCtx, "")
+
MODEL_LABELS[className] = {
"model": modelLabel,
"attributes": attributes,
diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py
index 0558f65b..53405683 100644
--- a/modules/system/mainSystem.py
+++ b/modules/system/mainSystem.py
@@ -510,9 +510,14 @@ RESOURCE_OBJECTS = [
},
{
"objectKey": "resource.store.trustee",
- "label": "Store: Trustee",
+ "label": t("Store: Trustee", context="UI"),
"meta": {"category": "store", "featureCode": "trustee"}
},
+ {
+ "objectKey": "resource.store.graphicalEditor",
+ "label": t("Store: Workflow-Automation", context="UI"),
+ "meta": {"category": "store", "featureCode": "graphicalEditor"}
+ },
{
"objectKey": "resource.system.api.auth",
"label": "Authentifizierungs-API",
diff --git a/tests/validation/test_featureCatalogLabels_i18n.py b/tests/validation/test_featureCatalogLabels_i18n.py
new file mode 100644
index 00000000..d6787c43
--- /dev/null
+++ b/tests/validation/test_featureCatalogLabels_i18n.py
@@ -0,0 +1,58 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Validation: every label in feature ``main*.py`` catalog lists must be wrapped in ``t(...)``.
+
+Background:
+ ``UI_OBJECTS``, ``DATA_OBJECTS``, ``RESOURCE_OBJECTS``, ``WORKFLOW_DEFINITIONS`` etc.
+ in ``gateway/modules/features//main*.py`` define labels that the UDB and other
+ UI surfaces display. Bare-string labels (``"label": "Konfiguration"``) are not
+ registered with the i18n catalog at module-import time, so non-DE renders show
+ them as ``[Konfiguration]`` (the missing-translation marker from
+ ``modules.shared.i18nRegistry.t()``).
+
+ This test scans every ``main*.py`` under ``gateway/modules/features`` and fails
+ if it finds bare-string labels, blocking the regression in CI.
+
+Allowed exceptions:
+ - Labels that are NOT user-visible (e.g. internal demo seed data, fixtures).
+ Add their file to ``_ALLOWED_FILES_WITH_BARE_LABELS`` with a justification.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+
+
+_FEATURES_DIR = Path(__file__).resolve().parents[2] / "modules" / "features"
+
+_BARE_LABEL_PATTERN = re.compile(r'^\s*"label"\s*:\s*"[^"]+"', re.MULTILINE)
+
+# mainRealEstate.py contains "label": "AA1704" inside a multi-line f-string
+# that is used as a JSON example in an AI prompt -- not a real catalog entry.
+_ALLOWED_FILES_WITH_BARE_LABELS: set[str] = {
+ "mainRealEstate.py",
+}
+
+
+def _findFeatureMainFiles() -> list[Path]:
+ return sorted(_FEATURES_DIR.glob("*/main*.py"))
+
+
+@pytest.mark.parametrize("mainFile", _findFeatureMainFiles(), ids=lambda p: p.name)
+def test_noBareLabelsInFeatureCatalog(mainFile: Path) -> None:
+ if mainFile.name in _ALLOWED_FILES_WITH_BARE_LABELS:
+ pytest.skip(f"{mainFile.name} explicitly allowed (legacy seed data)")
+
+ text = mainFile.read_text(encoding="utf-8")
+ matches = _BARE_LABEL_PATTERN.findall(text)
+ assert not matches, (
+ f"\n{mainFile.relative_to(_FEATURES_DIR.parent.parent)} contains "
+ f"{len(matches)} bare-string labels that are NOT registered with i18n.\n"
+ f"Wrap each label with t(\"...\", context=\"UI\") so non-DE locales\n"
+ f"don't render them as [missing-key].\n\n"
+ f"Sample offending lines:\n "
+ + "\n ".join(matches[:5])
+ )