Project unread postbox work with shared authorization

This commit is contained in:
2026-08-06 16:06:17 +02:00
parent d107c09f74
commit 36530b6dce
11 changed files with 450 additions and 54 deletions
+6
View File
@@ -168,6 +168,12 @@ unified inbox views and keep other responsibilities separate. Grouping is a
query projection only. It never merges source containers, messages, read or query projection only. It never merges source containers, messages, read or
acknowledgement state, retention, encryption keys, or audit evidence. acknowledgement state, retention, encryption keys, or audit evidence.
The optional Tasks module may aggregate available unread Postbox messages into
the common work inbox. This is a current, permission-rechecked projection of a
personal read receipt, not a copied task or message. Reading the message in
Postbox removes the projection; Postbox remains authoritative for content,
access, acknowledgement, reply, retention, and evidence.
Grouping summaries calculate total and unread counts over the currently Grouping summaries calculate total and unread counts over the currently
visible source Postboxes in one tenant-bounded query. Hidden sources retained visible source Postboxes in one tenant-bounded query. Hidden sources retained
for later reassignment do not leak counts into the projection. for later reassignment do not leak counts into the projection.
+30 -12
View File
@@ -45,27 +45,28 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ROUTING, CAPABILITY_POSTBOX_ROUTING,
) )
from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.tasks import WorkItemProviderRegistration
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_postbox.backend.db import models as postbox_models from govoplan_postbox.backend.db import models as postbox_models
from govoplan_postbox.backend.search_source import create_postbox_search_source from govoplan_postbox.backend.search_source import create_postbox_search_source
from govoplan_postbox.backend.permissions import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
DELIVERY_SCOPE,
READ_SCOPE,
REPLY_SCOPE,
RESTRICTED_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
MODULE_ID = "postbox" MODULE_ID = "postbox"
MODULE_NAME = "Postbox" MODULE_NAME = "Postbox"
MODULE_VERSION = "0.1.18" MODULE_VERSION = "0.1.18"
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
REPLY_SCOPE = "postbox:message:reply"
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
DELIVERY_SCOPE = "postbox:delivery:write"
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
RESTRICTED_SCOPE = "postbox:classification:restricted"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
@@ -169,6 +170,12 @@ def _router(context: ModuleContext):
return router return router
def _work_items(context: ModuleContext):
from govoplan_postbox.backend.work_items import PostboxWorkItemProvider
return PostboxWorkItemProvider(registry=context.registry)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return { return {
"postboxes": session.query(postbox_models.Postbox) "postboxes": session.query(postbox_models.Postbox)
@@ -225,6 +232,7 @@ manifest = ModuleManifest(
"views", "views",
"workflow_engine", "workflow_engine",
"search", "search",
"tasks",
), ),
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
@@ -284,6 +292,13 @@ manifest = ModuleManifest(
factory=create_postbox_search_source, factory=create_postbox_search_source,
), ),
), ),
work_item_providers=(
WorkItemProviderRegistration(
id="postbox.unread",
factory=_work_items,
order=40,
),
),
nav_items=( nav_items=(
NavItem( NavItem(
path="/postbox", path="/postbox",
@@ -413,7 +428,9 @@ manifest = ModuleManifest(
"server-envelope profile stores message bodies as ciphertext and " "server-envelope profile stores message bodies as ciphertext and "
"uses the optional Encryption capability for authorized reads. " "uses the optional Encryption capability for authorized reads. "
"External ciphertext profiles retain producer-managed references " "External ciphertext profiles retain producer-managed references "
"and keys; neither profile is described as end-to-end encryption." "and keys; neither profile is described as end-to-end encryption. "
"When Tasks is enabled, currently readable unread messages also appear "
"in the common work inbox and disappear when the personal read receipt is recorded."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -425,6 +442,7 @@ manifest = ModuleManifest(
"campaigns", "campaigns",
"files", "files",
"notifications", "notifications",
"tasks",
), ),
links=( links=(
DocumentationLink( DocumentationLink(
@@ -0,0 +1,25 @@
from __future__ import annotations
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
REPLY_SCOPE = "postbox:message:reply"
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
DELIVERY_SCOPE = "postbox:delivery:write"
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
RESTRICTED_SCOPE = "postbox:classification:restricted"
__all__ = [
"ACKNOWLEDGE_SCOPE",
"BINDING_ADMIN_SCOPE",
"CONFIDENTIAL_SCOPE",
"DELIVERY_SCOPE",
"READ_SCOPE",
"REPLY_SCOPE",
"RESTRICTED_SCOPE",
"SEND_SCOPE",
"TEMPLATE_ADMIN_SCOPE",
]
@@ -0,0 +1,65 @@
from __future__ import annotations
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.postbox import PostboxActorRef
from govoplan_postbox.backend.permissions import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
READ_SCOPE,
REPLY_SCOPE,
RESTRICTED_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
class PostboxPrincipalError(ValueError):
pass
def actor_from_principal(
principal: ApiPrincipal,
*,
assignment_context_id: str | None = None,
) -> PostboxActorRef:
actions: set[str] = set()
if has_scope(principal, READ_SCOPE):
actions.update(("discover", "read"))
if has_scope(principal, SEND_SCOPE):
actions.add("send")
if has_scope(principal, REPLY_SCOPE):
actions.add("reply")
if has_scope(principal, ACKNOWLEDGE_SCOPE):
actions.add("acknowledge")
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
principal,
TEMPLATE_ADMIN_SCOPE,
):
actions.add("administer")
if (
assignment_context_id is not None
and assignment_context_id not in principal.function_assignment_ids
):
raise PostboxPrincipalError(
"The selected assignment context is not active for this principal."
)
selected = assignment_context_id or principal.acting_assignment_id
if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids))
classifications = {"public", "internal"}
if has_scope(principal, CONFIDENTIAL_SCOPE):
classifications.add("confidential")
if has_scope(principal, RESTRICTED_SCOPE):
classifications.update(("confidential", "restricted"))
return PostboxActorRef(
account_id=principal.account_id,
identity_id=principal.identity_id,
selected_assignment_id=selected,
acting_for_account_id=principal.acting_for_account_id,
authorized_actions=frozenset(actions), # type: ignore[arg-type]
authorized_classifications=frozenset(classifications), # type: ignore[arg-type]
)
__all__ = ["PostboxPrincipalError", "actor_from_principal"]
+12 -38
View File
@@ -28,11 +28,9 @@ from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import ( from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE, ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE, BINDING_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
DELIVERY_SCOPE, DELIVERY_SCOPE,
READ_SCOPE, READ_SCOPE,
REPLY_SCOPE, REPLY_SCOPE,
RESTRICTED_SCOPE,
SEND_SCOPE, SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE,
) )
@@ -67,6 +65,10 @@ from govoplan_postbox.backend.schemas import (
PostboxTemplateReviseRequest, PostboxTemplateReviseRequest,
) )
from govoplan_postbox.backend.service import PostboxError from govoplan_postbox.backend.service import PostboxError
from govoplan_postbox.backend.principals import (
PostboxPrincipalError,
actor_from_principal,
)
router = APIRouter(prefix="/postbox", tags=["postbox"]) router = APIRouter(prefix="/postbox", tags=["postbox"])
@@ -94,44 +96,16 @@ def _actor(
*, *,
assignment_context_id: str | None = None, assignment_context_id: str | None = None,
) -> PostboxActorRef: ) -> PostboxActorRef:
actions: set[str] = set() try:
if has_scope(principal, READ_SCOPE): return actor_from_principal(
actions.update(("discover", "read")) principal,
if has_scope(principal, SEND_SCOPE): assignment_context_id=assignment_context_id,
actions.add("send") )
if has_scope(principal, REPLY_SCOPE): except PostboxPrincipalError as exc:
actions.add("reply")
if has_scope(principal, ACKNOWLEDGE_SCOPE):
actions.add("acknowledge")
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
principal,
TEMPLATE_ADMIN_SCOPE,
):
actions.add("administer")
if (
assignment_context_id is not None
and assignment_context_id not in principal.function_assignment_ids
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="The selected assignment context is not active for this principal.", detail=str(exc),
) ) from exc
selected = assignment_context_id or principal.acting_assignment_id
if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids))
classifications = {"public", "internal"}
if has_scope(principal, CONFIDENTIAL_SCOPE):
classifications.add("confidential")
if has_scope(principal, RESTRICTED_SCOPE):
classifications.update(("confidential", "restricted"))
return PostboxActorRef(
account_id=principal.account_id,
identity_id=principal.identity_id,
selected_assignment_id=selected,
acting_for_account_id=principal.acting_for_account_id,
authorized_actions=frozenset(actions), # type: ignore[arg-type]
authorized_classifications=frozenset(classifications), # type: ignore[arg-type]
)
def _http_error(exc: PostboxError) -> HTTPException: def _http_error(exc: PostboxError) -> HTTPException:
+65
View File
@@ -739,6 +739,71 @@ class PostboxService:
or 0 or 0
) )
def list_available_unread_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
limit: int = 100,
query: str | None = None,
) -> tuple[tuple[PostboxMessageRef, ...], int]:
"""Return unread messages that can still be acted on by this actor."""
db = _session(session)
allowed_ids = self._allowed_postbox_ids(
db,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
actor=actor,
action="read",
)
if not allowed_ids:
return (), 0
now = utc_now()
messages = self._messages_query(
db,
tenant_id=tenant_id,
allowed_ids=allowed_ids,
account_id=actor.account_id,
allowed_classifications=tuple(actor.authorized_classifications),
query=query,
state="unread",
).filter(
PostboxMessage.withdrawn_at.is_(None),
or_(
PostboxMessage.expires_at.is_(None),
PostboxMessage.expires_at > now,
),
)
total = int(
messages.order_by(None)
.with_entities(func.count(PostboxMessage.id))
.scalar()
or 0
)
rows = (
messages.options(
selectinload(PostboxMessage.participants),
selectinload(PostboxMessage.attachments),
selectinload(PostboxMessage.receipts),
)
.order_by(
PostboxMessage.delivered_at.desc(),
PostboxMessage.id.desc(),
)
.limit(min(max(limit, 1), 500))
.all()
)
return (
tuple(
self._message_ref(message, account_id=actor.account_id)
for message in rows
),
total,
)
def message_counts_by_postbox( def message_counts_by_postbox(
self, self,
session: object, session: object,
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxMessageRef
from govoplan_core.core.tasks import (
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_postbox.backend.permissions import READ_SCOPE
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.service import PostboxService
PROVIDER_ID = "postbox.unread"
class PostboxWorkItemProvider:
def __init__(
self,
*,
registry: object | None = None,
service: PostboxService | None = None,
) -> None:
self.registry = registry
self.service = service
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Postbox work aggregation requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
return WorkItemPage(items=(), total=0)
if principal.tenant_id != query.tenant_id or not has_scope(
principal, READ_SCOPE
):
return WorkItemPage(items=(), total=0)
if query.statuses and "open" not in query.statuses:
return WorkItemPage(items=(), total=0)
if query.priorities and "normal" not in query.priorities:
return WorkItemPage(items=(), total=0)
if query.due_before is not None:
return WorkItemPage(items=(), total=0)
actor = actor_from_principal(principal)
service = self._service()
postboxes = service.list_visible_postboxes(
session,
tenant_id=query.tenant_id,
actor=actor,
)
by_id = {postbox.id: postbox for postbox in postboxes}
messages, total = service.list_available_unread_messages(
session,
tenant_id=query.tenant_id,
postbox_ids=tuple(by_id),
actor=actor,
limit=query.limit,
query=query.text,
)
return WorkItemPage(
items=tuple(
_work_item(message, by_id[message.postbox_id], principal)
for message in messages
),
total=total,
truncated=total > len(messages),
)
def _service(self) -> PostboxService:
if self.service is not None:
return self.service
if self.registry is None:
raise RuntimeError("Postbox work aggregation requires a registry.")
return PostboxService.from_registry(self.registry) # type: ignore[arg-type]
def _work_item(
message: PostboxMessageRef,
postbox: PostboxDirectoryEntryRef,
principal: ApiPrincipal,
) -> WorkItem:
action_url = (
f"/postbox?postbox={quote(message.postbox_id, safe='')}"
f"&message={quote(message.id, safe='')}"
)
sources = [
WorkSourceRef(
module_id="postbox",
resource_type="postbox_message",
resource_id=message.id,
revision=message.delivered_at.isoformat(),
url=action_url,
label=message.subject,
)
]
if (
message.producer_module
and message.producer_resource_type
and message.producer_resource_id
):
sources.append(
WorkSourceRef(
module_id=message.producer_module,
resource_type=message.producer_resource_type,
resource_id=message.producer_resource_id,
)
)
return WorkItem(
id=message.id,
provider_id=PROVIDER_ID,
owner_module="postbox",
tenant_id=message.tenant_id,
title=message.subject,
summary=(
f"{postbox.name} · {message.sender_label}"
if message.sender_label
else postbox.name
),
status="open",
priority="normal",
required_action="Read the Postbox message.",
action_url=action_url,
assignments=_assignments(postbox, principal),
sources=tuple(sources),
provenance={
"postbox_id": postbox.id,
"address_key": postbox.address_key,
"producer_module": message.producer_module,
},
metadata={
"classification": message.classification,
"attachment_count": len(message.attachments),
"encrypted": message.encryption_profile != "plaintext_v1",
},
revision=f"{message.status}:{message.delivered_at.isoformat()}",
created_at=message.delivered_at,
updated_at=message.delivered_at,
)
def _assignments(
postbox: PostboxDirectoryEntryRef,
principal: ApiPrincipal,
) -> tuple[WorkAssignmentRef, ...]:
access = postbox.access
assignment_ids = tuple(access.assignment_ids) if access is not None else ()
if access is not None and access.selected_assignment_id:
assignment_ids = (access.selected_assignment_id,)
assignments = tuple(
WorkAssignmentRef(
kind="function_assignment",
id=assignment_id,
label=postbox.function_name,
)
for assignment_id in assignment_ids
)
if assignments:
return assignments
return (WorkAssignmentRef(kind="account", id=principal.account_id),)
__all__ = ["PROVIDER_ID", "PostboxWorkItemProvider"]
+1
View File
@@ -41,6 +41,7 @@ class PostboxManifestTests(unittest.TestCase):
manifest.required_capabilities, manifest.required_capabilities,
) )
self.assertIn("encryption", manifest.optional_dependencies) self.assertIn("encryption", manifest.optional_dependencies)
self.assertEqual("postbox.unread", manifest.work_item_providers[0].id)
self.assertTrue( self.assertTrue(
any( any(
requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER
+66
View File
@@ -6,7 +6,10 @@ from datetime import timedelta
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.postbox import PostboxActorRef from govoplan_core.core.postbox import PostboxActorRef
from govoplan_core.core.tasks import WorkItemQuery
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.db.session import ( from govoplan_core.db.session import (
DatabaseHandle, DatabaseHandle,
@@ -41,6 +44,8 @@ from govoplan_postbox.backend.db.models import (
PostboxTemplateRevision, PostboxTemplateRevision,
) )
from govoplan_postbox.backend.service import PostboxService from govoplan_postbox.backend.service import PostboxService
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.work_items import PostboxWorkItemProvider
TABLES = ( TABLES = (
@@ -269,6 +274,67 @@ class PostboxRealDirectoryAccessTests(unittest.TestCase):
self.assertFalse(expired.allowed) self.assertFalse(expired.allowed)
self.assertEqual(expired.reason_code, "effective_assignment_missing") self.assertEqual(expired.reason_code, "effective_assignment_missing")
def test_unread_message_is_projected_as_current_work_until_read(self) -> None:
self._add_assignment(
assignment_id="owner-assignment",
identity_id="identity-owner",
account_id="account-owner",
)
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-owner",
membership_id="membership-owner",
tenant_id="tenant-1",
identity_id="identity-owner",
scopes=frozenset({"postbox:postbox:read"}),
function_assignment_ids=frozenset({"owner-assignment"}),
),
account=object(),
user=object(),
)
provider = PostboxWorkItemProvider(service=self.service)
with self.database.SessionLocal() as session:
message = PostboxMessage(
tenant_id="tenant-1",
postbox_id=self.postbox_id,
subject="Review the submitted evidence",
status="delivered",
classification="internal",
sender_label="Permit service",
delivered_at=utc_now(),
wrapped_keys=[],
external_recipient_tokens=[],
metadata_={},
)
session.add(message)
session.commit()
page = provider.list_items(
session,
principal,
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(1, page.total)
self.assertEqual(message.id, page.items[0].id)
self.assertEqual("owner-assignment", page.items[0].assignments[0].id)
self.service.mark_message(
session,
tenant_id="tenant-1",
message_id=message.id,
actor=actor_from_principal(principal),
state="read",
)
session.commit()
self.assertEqual(
0,
provider.list_items(
session,
principal,
query=WorkItemQuery(tenant_id="tenant-1"),
).total,
)
def test_real_organization_state_and_function_move_fail_closed(self) -> None: def test_real_organization_state_and_function_move_fail_closed(self) -> None:
self._add_assignment( self._add_assignment(
assignment_id="owner-assignment", assignment_id="owner-assignment",
@@ -22,10 +22,9 @@ assert(page.includes("useUnsavedDraftGuard") && admin.includes("useUnsavedDraftG
assert(page.includes("delete_grouping_confirmation") && page.includes("ConfirmDialog"), "Deleting a unified view confirms that source records remain unchanged"); assert(page.includes("delete_grouping_confirmation") && page.includes("ConfirmDialog"), "Deleting a unified view confirms that source records remain unchanged");
assert(admin.includes("archive_confirmation") && admin.includes("retire_template_confirmation"), "Address and template lifecycle actions use shared destructive confirmation"); assert(admin.includes("archive_confirmation") && admin.includes("retire_template_confirmation"), "Address and template lifecycle actions use shared destructive confirmation");
assert(patterns.includes('topicId: "postbox.function-bound-containers"') && patterns.includes('topicId: "postbox.reference.fields-and-consequences"'), "Postbox uses manifest-backed help references"); assert(patterns.includes('topicId: "postbox.function-bound-containers"') && patterns.includes('topicId: "postbox.reference.fields-and-consequences"'), "Postbox uses manifest-backed help references");
assert(moduleSource.includes('version: "0.1.2"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations"); assert(moduleSource.includes('version: "0.1.18"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations");
assert(translations.includes('"i18n:govoplan-postbox.unavailable_message_reason"'), "Access-sensitive unavailable states are localized"); assert(translations.includes('"i18n:govoplan-postbox.unavailable_message_reason"'), "Access-sensitive unavailable states are localized");
assert(widget.includes("usePlatformLanguage") && widget.includes("i18nMessage"), "Widget dates and dynamic accessible labels follow the platform locale"); assert(widget.includes("usePlatformLanguage") && widget.includes("i18nMessage"), "Widget dates and dynamic accessible labels follow the platform locale");
assert(!page.includes("window.confirm") && !admin.includes("window.confirm"), "Postbox does not use browser-native consequential confirmation"); assert(!page.includes("window.confirm") && !admin.includes("window.confirm"), "Postbox does not use browser-native consequential confirmation");
console.log("Postbox surfaces satisfy the recorded interface pattern-language contract."); console.log("Postbox surfaces satisfy the recorded interface pattern-language contract.");
+5 -2
View File
@@ -87,19 +87,22 @@ const postboxAdminSections: AdminSectionsUiCapability = {
export const postboxModule: PlatformWebModule = { export const postboxModule: PlatformWebModule = {
id: "postbox", id: "postbox",
label: "i18n:govoplan-postbox.postbox", label: "i18n:govoplan-postbox.postbox",
version: "0.1.2", version: "0.1.18",
dependencies: ["identity", "organizations", "idm"], dependencies: ["identity", "organizations", "idm"],
optionalDependencies: [ optionalDependencies: [
"access", "access",
"audit", "audit",
"campaigns", "campaigns",
"encryption",
"files", "files",
"mail", "mail",
"notifications", "notifications",
"policy", "policy",
"portal", "portal",
"search",
"tasks",
"views", "views",
"workflow" "workflow_engine"
], ],
translations, translations,
navItems: [ navItems: [