diff --git a/docs/env.md b/docs/env.md
index 2c60d3a0..f9f4743e 100644
--- a/docs/env.md
+++ b/docs/env.md
@@ -255,9 +255,9 @@ _Those settings are deprecated and will be removed in the future._
| Variable | Default | Description | Required |
|----------|---------|-------------|----------|
-| `SENTRY_DSN` | None | Sentry DSN for error tracking | Optional |
-| `NEXT_PUBLIC_SENTRY_DSN` | None | Sentry DSN for error tracking | Optional |
-| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | None | Sentry environment for error tracking | Optional ('production', 'development', 'staging') |
+| `SENTRY_DSN` | None | Sentry DSN for error tracking, shared with the frontend through the `/config` endpoint | Optional |
+| `NEXT_PUBLIC_SENTRY_DSN` | None | **Deprecated** (build-time fallback, will be removed) — use `SENTRY_DSN` | Optional |
+| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | None | **Deprecated** (build-time fallback, will be removed) — the frontend now uses the backend `ENVIRONMENT` | Optional |
### Selfcheck
@@ -296,18 +296,30 @@ End-to-end mail delivery probe — see [selfcheck.md](selfcheck.md) for details.
## Frontend Configuration
+The frontend is configured at runtime through the backend `/api/v1.0/config/` endpoint (see the backend [Frontend settings](#frontend) section). The only build-time variable left is the API origin, needed to reach that endpoint.
+
| Variable | Default | Description | Required |
|----------|---------|-------------|----------|
| `NEXT_PUBLIC_API_ORIGIN` | `http://localhost:8901` | Frontend API origin | Dev |
-| `NEXT_PUBLIC_LANGUAGES` | `[["en-US","English"],["fr-FR","Français"],["nl-NL","Nederlands"]]` | Languages available for frontend | Optional |
-| `NEXT_PUBLIC_DEFAULT_LANGUAGE` | `en-US` | Default language for frontend | Optional |
-| `NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE` | `false` | When `true`, the default language fallback is `NEXT_PUBLIC_DEFAULT_LANGUAGE` instead of the browser language. | Optional |
-| `NEXT_PUBLIC_THEME_CONFIG` | `{theme: "white-label"}` | Theme configuration for frontend | Optional |
-| `NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL` || Feedback widget API URL | Optional |
-| `NEXT_PUBLIC_FEEDBACK_WIDGET_PATH` || Feedback widget path | Optional |
-| `NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL` || Feedback widget channel used by the in-app survey button (authenticated header) | Optional |
-| `NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL` || Feedback widget channel used on the unauthenticated home page. Falls back to `NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL` when unset | Optional |
-| `NEXT_PUBLIC_HELP_CENTER_URL` || Help center URL | Optional |
+
+The following build-time variables are **deprecated**: they only act as fallbacks when the backend does not provide the corresponding setting, and will be removed in a future release.
+
+| Deprecated variable | Replaced by (backend setting) |
+|----------|---------|
+| `NEXT_PUBLIC_LANGUAGES` | `LANGUAGES` |
+| `NEXT_PUBLIC_DEFAULT_LANGUAGE` | `LANGUAGE_CODE` |
+| `NEXT_PUBLIC_FORCED_DEFAULT_LANGUAGE` | `FRONTEND_FORCED_DEFAULT_LANGUAGE` |
+| `NEXT_PUBLIC_THEME_CONFIG` | `FRONTEND_THEME_CONFIG` |
+| `NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE` | `FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB` |
+| `NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`api_url` key) |
+| `NEXT_PUBLIC_FEEDBACK_WIDGET_PATH` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`path` key) |
+| `NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`channel` key) |
+| `NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL` | `FRONTEND_FEEDBACK_WIDGET_CONFIG` (`home_channel` key) |
+| `NEXT_PUBLIC_HELP_CENTER_URL` | `FRONTEND_HELP_CENTER_URL` |
+| `NEXT_PUBLIC_LAGAUFRE_WIDGET_API_URL` | `FRONTEND_LAGAUFRE_WIDGET_CONFIG` (`api_url` key) |
+| `NEXT_PUBLIC_LAGAUFRE_WIDGET_PATH` | `FRONTEND_LAGAUFRE_WIDGET_CONFIG` (`path` key) |
+| `NEXT_PUBLIC_SENTRY_DSN` | `SENTRY_DSN` |
+| `NEXT_PUBLIC_SENTRY_ENVIRONMENT` | `ENVIRONMENT` (backend environment) |
## Development Tools
@@ -410,10 +422,19 @@ it can lead to memory exhaustion, increase at your own risk.
### Frontend
+These settings are unset by default: an unset setting is omitted from the `/config` payload and the frontend then falls back on its deprecated `NEXT_PUBLIC_*` build-time variable (if any), then on its built-in default. Setting a value here always takes precedence over the frontend fallbacks.
+
| Variable | Default | Description | Required |
|----------|---------|-------------|----------|
-| `FRONTEND_THEME` | `white-label` | Theme for the frontend | Optional |
| `FRONTEND_SILENT_LOGIN_ENABLED` | `False` | Whether silent login is enabled | Optional |
+| `FRONTEND_THEME_CONFIG` | None (frontend defaults to `{"theme": "white-label"}`) | Theme configuration served to the frontend (`theme`, `terms_of_service_url`, `footer`), as JSON | Optional |
+| `FRONTEND_FORCED_DEFAULT_LANGUAGE` | None (frontend defaults to `False`) | When `True`, the frontend default language fallback is `LANGUAGE_CODE` instead of the browser language | Optional |
+| `FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB` | None (frontend defaults to `100`) | Chunk size in MB for frontend multipart uploads | Optional |
+| `FRONTEND_HELP_CENTER_URL` | None | Help center URL | Optional |
+| `FRONTEND_FEEDBACK_WIDGET_CONFIG` | None | Feedback widget configuration (`api_url`, `path`, `channel`, `home_channel`), as JSON | Optional |
+| `FRONTEND_LAGAUFRE_WIDGET_CONFIG` | None | Lagaufre widget configuration (`api_url`, `path`), as JSON | Optional |
+
+Note: every language listed in `LANGUAGES` must have its translation files in the frontend (`/locales/*/xx-XX.json`), otherwise the UI falls back to `en-US`.
### Third-party Services
diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults
index c20243fb..826c6532 100644
--- a/env.d/development/backend.defaults
+++ b/env.d/development/backend.defaults
@@ -83,7 +83,12 @@ KEYCLOAK_CLIENT_SECRET=ServiceAccountClientSecretForDev
KEYCLOAK_GROUP_PATH_PREFIX=/maildomain-
# Frontend
-FRONTEND_THEME=dsfr
+# FRONTEND_THEME_CONFIG={"theme": "white-label"}
+# FRONTEND_FORCED_DEFAULT_LANGUAGE=false
+# FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB=100
+# FRONTEND_HELP_CENTER_URL=https://help.example.com
+# FRONTEND_FEEDBACK_WIDGET_CONFIG={"api_url": "", "path": "", "channel": "", "home_channel": ""}
+# FRONTEND_LAGAUFRE_WIDGET_CONFIG={"api_url": "", "path": ""}
# Messages
MESSAGES_TESTDOMAIN=example.local
diff --git a/env.d/development/frontend.defaults b/env.d/development/frontend.defaults
index 64ab68d2..aacda217 100644
--- a/env.d/development/frontend.defaults
+++ b/env.d/development/frontend.defaults
@@ -1,20 +1,3 @@
+# The API origin is the only build-time variable left: it is needed to reach
+# the backend /config endpoint that provides the rest of the configuration.
NEXT_PUBLIC_API_ORIGIN=http://localhost:8901
-NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL=
-NEXT_PUBLIC_FEEDBACK_WIDGET_PATH=
-NEXT_PUBLIC_FEEDBACK_WIDGET_CHANNEL=
-NEXT_PUBLIC_FEEDBACK_WIDGET_HOME_CHANNEL=
-NEXT_PUBLIC_HELP_CENTER_URL=
-NEXT_PUBLIC_LANGUAGES=[["en-US","English"],["fr-FR","Français"],["nl-NL","Nederlands"]]
-NEXT_PUBLIC_DEFAULT_LANGUAGE=en-US
-## Chunk size for multipart upload in MB
-NEXT_PUBLIC_MULTIPART_UPLOAD_CHUNK_SIZE=100
-
-## Sentry
-NEXT_PUBLIC_SENTRY_DSN=
-NEXT_PUBLIC_SENTRY_ENVIRONMENT=
-
-## Theme Customization
-NEXT_PUBLIC_THEME_CONFIG='{
- "theme": "white-label"
-}'
-
diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json
index 32f2b935..d377447c 100644
--- a/src/backend/core/api/openapi.json
+++ b/src/backend/core/api/openapi.json
@@ -203,10 +203,19 @@
"type": "string",
"readOnly": true
},
+ "RELEASE": {
+ "type": "string",
+ "description": "Version of the application",
+ "readOnly": true
+ },
"LANGUAGES": {
"type": "array",
+ "description": "Available languages, as (code, label) pairs",
"items": {
- "type": "string"
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
"readOnly": true
},
@@ -238,34 +247,29 @@
"description": "The URLs of the Drive external service.",
"properties": {
"sdk_url": {
- "type": "string",
- "readOnly": true
+ "type": "string"
},
"api_url": {
- "type": "string",
- "readOnly": true
+ "type": "string"
},
"file_url": {
- "type": "string",
- "readOnly": true
+ "type": "string"
},
"preview_url": {
- "type": "string",
- "readOnly": true
+ "type": "string"
},
"app_name": {
- "type": "string",
- "readOnly": true
+ "type": "string"
}
},
- "readOnly": true,
"required": [
"sdk_url",
"api_url",
"file_url",
"preview_url",
"app_name"
- ]
+ ],
+ "readOnly": true
},
"SCHEMA_CUSTOM_ATTRIBUTES_USER": {
"type": "object",
@@ -280,16 +284,6 @@
"description": "Maximum size in bytes for outgoing email attachments",
"readOnly": true
},
- "MAX_OUTGOING_BODY_SIZE": {
- "type": "integer",
- "description": "Maximum size in bytes for outgoing email body (text + HTML)",
- "readOnly": true
- },
- "MAX_INCOMING_EMAIL_SIZE": {
- "type": "integer",
- "description": "Maximum size in bytes for incoming email (including attachments and body)",
- "readOnly": true
- },
"MAX_RECIPIENTS_PER_MESSAGE": {
"type": "integer",
"description": "Maximum number of recipients per message (to + cc + bcc)",
@@ -330,10 +324,68 @@
"type": "boolean",
"description": "Whether silent OIDC login is enabled",
"readOnly": true
+ },
+ "SENTRY_DSN": {
+ "type": "string",
+ "description": "Sentry DSN shared with the frontend",
+ "readOnly": true
+ },
+ "FRONTEND_THEME_CONFIG": {
+ "type": "object",
+ "description": "Theme configuration for the frontend (theme, terms_of_service_url, footer)",
+ "readOnly": true
+ },
+ "FRONTEND_FORCED_DEFAULT_LANGUAGE": {
+ "type": "boolean",
+ "description": "Whether the frontend should fall back to LANGUAGE_CODE instead of the browser language",
+ "readOnly": true
+ },
+ "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB": {
+ "type": "integer",
+ "description": "Chunk size in MB for frontend multipart uploads",
+ "readOnly": true
+ },
+ "FRONTEND_HELP_CENTER_URL": {
+ "type": "string",
+ "description": "Help center URL",
+ "readOnly": true
+ },
+ "FRONTEND_FEEDBACK_WIDGET_CONFIG": {
+ "type": "object",
+ "description": "Configuration of the feedback widget",
+ "properties": {
+ "api_url": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "channel": {
+ "type": "string"
+ },
+ "home_channel": {
+ "type": "string"
+ }
+ },
+ "readOnly": true
+ },
+ "FRONTEND_LAGAUFRE_WIDGET_CONFIG": {
+ "type": "object",
+ "description": "Configuration of the Lagaufre widget",
+ "properties": {
+ "api_url": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ },
+ "readOnly": true
}
},
"required": [
"ENVIRONMENT",
+ "RELEASE",
"LANGUAGES",
"LANGUAGE_CODE",
"AI_ENABLED",
@@ -343,8 +395,6 @@
"SCHEMA_CUSTOM_ATTRIBUTES_USER",
"SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN",
"MAX_OUTGOING_ATTACHMENT_SIZE",
- "MAX_OUTGOING_BODY_SIZE",
- "MAX_INCOMING_EMAIL_SIZE",
"MAX_RECIPIENTS_PER_MESSAGE",
"MAX_TEMPLATE_IMAGE_SIZE",
"IMAGE_PROXY_ENABLED",
diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py
index f5fa054b..f73b5378 100644
--- a/src/backend/core/api/viewsets/config.py
+++ b/src/backend/core/api/viewsets/config.py
@@ -1,5 +1,8 @@
"""API ViewSet for sharing some public settings."""
+from dataclasses import dataclass, field
+from typing import Any, Callable
+
from django.conf import settings
import rest_framework as drf
@@ -10,6 +13,216 @@ from core.ai.utils import is_ai_enabled, is_ai_summary_enabled, is_auto_labels_e
from core.services.identity.keycloak import is_mandatory_totp_enabled
+def _get_drive_config():
+ """Build the Drive external service URLs, or None when not configured."""
+ base_url = settings.DRIVE_CONFIG.get("base_url")
+ if not base_url:
+ return None
+ return {
+ "sdk_url": f"{base_url}{settings.DRIVE_CONFIG.get('sdk_url')}",
+ "api_url": f"{base_url}{settings.DRIVE_CONFIG.get('api_url')}",
+ "file_url": f"{base_url}{settings.DRIVE_CONFIG.get('file_url')}",
+ "preview_url": f"{base_url}{settings.DRIVE_CONFIG.get('preview_url')}",
+ "app_name": settings.DRIVE_CONFIG.get("app_name"),
+ }
+
+
+@dataclass(frozen=True)
+class ConfigEntry:
+ """A public setting exposed to the frontend through the config endpoint."""
+
+ key: str
+ schema: dict = field(default_factory=lambda: {"type": "string"})
+ # Defaults to reading the setting named `key`; entries whose value is
+ # computed (feature flags, composed URLs) provide their own getter.
+ getter: Callable[[], Any] | None = None
+ # Non-required entries are omitted from the response when their value
+ # is None, and generated as optional fields in the API client.
+ required: bool = True
+
+ def resolve(self):
+ """Return the value to expose for this entry."""
+ if self.getter is not None:
+ return self.getter()
+ return getattr(settings, self.key, None)
+
+
+CONFIG_ENTRIES = (
+ ConfigEntry("ENVIRONMENT"),
+ ConfigEntry(
+ "RELEASE",
+ {"type": "string", "description": "Version of the application"},
+ ),
+ ConfigEntry(
+ "LANGUAGES",
+ {
+ "type": "array",
+ "description": "Available languages, as (code, label) pairs",
+ "items": {"type": "array", "items": {"type": "string"}},
+ },
+ ),
+ ConfigEntry("LANGUAGE_CODE"),
+ ConfigEntry("AI_ENABLED", {"type": "boolean"}, getter=is_ai_enabled),
+ ConfigEntry(
+ "FEATURE_AI_SUMMARY", {"type": "boolean"}, getter=is_ai_summary_enabled
+ ),
+ ConfigEntry(
+ "FEATURE_AI_AUTOLABELS", {"type": "boolean"}, getter=is_auto_labels_enabled
+ ),
+ ConfigEntry(
+ "FEATURE_MAILBOX_ADMIN_CHANNELS", {"type": "array", "items": {"type": "string"}}
+ ),
+ ConfigEntry(
+ "DRIVE",
+ {
+ "type": "object",
+ "description": "The URLs of the Drive external service.",
+ "properties": {
+ "sdk_url": {"type": "string"},
+ "api_url": {"type": "string"},
+ "file_url": {"type": "string"},
+ "preview_url": {"type": "string"},
+ "app_name": {"type": "string"},
+ },
+ "required": ["sdk_url", "api_url", "file_url", "preview_url", "app_name"],
+ },
+ getter=_get_drive_config,
+ required=False,
+ ),
+ ConfigEntry("SCHEMA_CUSTOM_ATTRIBUTES_USER", {"type": "object"}),
+ ConfigEntry("SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", {"type": "object"}),
+ ConfigEntry(
+ "MAX_OUTGOING_ATTACHMENT_SIZE",
+ {
+ "type": "integer",
+ "description": "Maximum size in bytes for outgoing email attachments",
+ },
+ ),
+ ConfigEntry(
+ "MAX_RECIPIENTS_PER_MESSAGE",
+ {
+ "type": "integer",
+ "description": "Maximum number of recipients per message (to + cc + bcc)",
+ },
+ ),
+ ConfigEntry(
+ "MAX_TEMPLATE_IMAGE_SIZE",
+ {
+ "type": "integer",
+ "description": (
+ "Maximum size in bytes for images embedded in templates and signatures"
+ ),
+ },
+ ),
+ ConfigEntry(
+ "IMAGE_PROXY_ENABLED",
+ {
+ "type": "boolean",
+ "description": "Whether external images should be proxied",
+ },
+ ),
+ ConfigEntry("FEATURE_MAILDOMAIN_CREATE", {"type": "boolean"}),
+ ConfigEntry("FEATURE_MAILDOMAIN_MANAGE_ACCESSES", {"type": "boolean"}),
+ ConfigEntry("FEATURE_THREAD_SPLIT", {"type": "boolean"}),
+ # Expose the *effective* mandatory-TOTP capability rather than the raw
+ # flag: the feature also requires IDENTITY_PROVIDER == "keycloak" and a
+ # populated KEYCLOAK_TOTP_ROLE_ID. Surfacing the raw flag would let the
+ # frontend render TOTP affordances that the backend silently refuses.
+ ConfigEntry(
+ "FEATURE_MAILDOMAIN_MANAGE_TOTP",
+ {"type": "boolean"},
+ getter=is_mandatory_totp_enabled,
+ ),
+ ConfigEntry(
+ "MESSAGES_MANUAL_RETRY_MAX_AGE",
+ {
+ "type": "integer",
+ "description": (
+ "Maximum age in seconds for a message to be eligible "
+ "for manual retry of failed deliveries"
+ ),
+ },
+ ),
+ ConfigEntry(
+ "FRONTEND_SILENT_LOGIN_ENABLED",
+ {
+ "type": "boolean",
+ "description": "Whether silent OIDC login is enabled",
+ },
+ ),
+ ConfigEntry(
+ "SENTRY_DSN",
+ {"type": "string", "description": "Sentry DSN shared with the frontend"},
+ required=False,
+ ),
+ # The FRONTEND_* entries below must stay optional (omitted when the
+ # backend setting is unset) as long as the frontend keeps its deprecated
+ # NEXT_PUBLIC_* fallbacks: sending a backend default here would silently
+ # override those build-time variables.
+ ConfigEntry(
+ "FRONTEND_THEME_CONFIG",
+ {
+ "type": "object",
+ "description": (
+ "Theme configuration for the frontend "
+ "(theme, terms_of_service_url, footer)"
+ ),
+ },
+ required=False,
+ ),
+ ConfigEntry(
+ "FRONTEND_FORCED_DEFAULT_LANGUAGE",
+ {
+ "type": "boolean",
+ "description": (
+ "Whether the frontend should fall back to LANGUAGE_CODE "
+ "instead of the browser language"
+ ),
+ },
+ required=False,
+ ),
+ ConfigEntry(
+ "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB",
+ {
+ "type": "integer",
+ "description": "Chunk size in MB for frontend multipart uploads",
+ },
+ required=False,
+ ),
+ ConfigEntry(
+ "FRONTEND_HELP_CENTER_URL",
+ {"type": "string", "description": "Help center URL"},
+ required=False,
+ ),
+ ConfigEntry(
+ "FRONTEND_FEEDBACK_WIDGET_CONFIG",
+ {
+ "type": "object",
+ "description": "Configuration of the feedback widget",
+ "properties": {
+ "api_url": {"type": "string"},
+ "path": {"type": "string"},
+ "channel": {"type": "string"},
+ "home_channel": {"type": "string"},
+ },
+ },
+ required=False,
+ ),
+ ConfigEntry(
+ "FRONTEND_LAGAUFRE_WIDGET_CONFIG",
+ {
+ "type": "object",
+ "description": "Configuration of the Lagaufre widget",
+ "properties": {
+ "api_url": {"type": "string"},
+ "path": {"type": "string"},
+ },
+ },
+ required=False,
+ ),
+)
+
+
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
@@ -23,157 +236,11 @@ class ConfigView(drf.views.APIView):
response={
"type": "object",
"properties": {
- "ENVIRONMENT": {"type": "string", "readOnly": True},
- "LANGUAGES": {
- "type": "array",
- "items": {"type": "string"},
- "readOnly": True,
- },
- "LANGUAGE_CODE": {"type": "string", "readOnly": True},
- "AI_ENABLED": {"type": "boolean", "readOnly": True},
- "FEATURE_AI_SUMMARY": {
- "type": "boolean",
- "readOnly": True,
- },
- "FEATURE_AI_AUTOLABELS": {
- "type": "boolean",
- "readOnly": True,
- },
- "FEATURE_MAILBOX_ADMIN_CHANNELS": {
- "type": "array",
- "items": {"type": "string"},
- "readOnly": True,
- },
- "DRIVE": {
- "type": "object",
- "description": "The URLs of the Drive external service.",
- "properties": {
- "sdk_url": {
- "type": "string",
- "readOnly": True,
- },
- "api_url": {
- "type": "string",
- "readOnly": True,
- },
- "file_url": {
- "type": "string",
- "readOnly": True,
- },
- "preview_url": {
- "type": "string",
- "readOnly": True,
- },
- "app_name": {
- "type": "string",
- "readOnly": True,
- },
- },
- "readOnly": True,
- "required": [
- "sdk_url",
- "api_url",
- "file_url",
- "preview_url",
- "app_name",
- ],
- },
- "SCHEMA_CUSTOM_ATTRIBUTES_USER": {
- "type": "object",
- "readOnly": True,
- },
- "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": {
- "type": "object",
- "readOnly": True,
- },
- "MAX_OUTGOING_ATTACHMENT_SIZE": {
- "type": "integer",
- "description": "Maximum size in bytes for outgoing email attachments",
- "readOnly": True,
- },
- "MAX_OUTGOING_BODY_SIZE": {
- "type": "integer",
- "description": "Maximum size in bytes for outgoing email body (text + HTML)",
- "readOnly": True,
- },
- "MAX_INCOMING_EMAIL_SIZE": {
- "type": "integer",
- "description": (
- "Maximum size in bytes for incoming email "
- "(including attachments and body)"
- ),
- "readOnly": True,
- },
- "MAX_RECIPIENTS_PER_MESSAGE": {
- "type": "integer",
- "description": (
- "Maximum number of recipients per message "
- "(to + cc + bcc)"
- ),
- "readOnly": True,
- },
- "MAX_TEMPLATE_IMAGE_SIZE": {
- "type": "integer",
- "description": "Maximum size in bytes for images embedded in templates and signatures",
- "readOnly": True,
- },
- "IMAGE_PROXY_ENABLED": {
- "type": "boolean",
- "description": "Whether external images should be proxied",
- "readOnly": True,
- },
- "FEATURE_MAILDOMAIN_CREATE": {
- "type": "boolean",
- "readOnly": True,
- },
- "FEATURE_MAILDOMAIN_MANAGE_ACCESSES": {
- "type": "boolean",
- "readOnly": True,
- },
- "FEATURE_THREAD_SPLIT": {
- "type": "boolean",
- "readOnly": True,
- },
- "FEATURE_MAILDOMAIN_MANAGE_TOTP": {
- "type": "boolean",
- "readOnly": True,
- },
- "MESSAGES_MANUAL_RETRY_MAX_AGE": {
- "type": "integer",
- "description": (
- "Maximum age in seconds for a message to be eligible "
- "for manual retry of failed deliveries"
- ),
- "readOnly": True,
- },
- "FRONTEND_SILENT_LOGIN_ENABLED": {
- "type": "boolean",
- "description": "Whether silent OIDC login is enabled",
- "readOnly": True,
- },
+ entry.key: {**entry.schema, "readOnly": True}
+ for entry in CONFIG_ENTRIES
},
"required": [
- "ENVIRONMENT",
- "LANGUAGES",
- "LANGUAGE_CODE",
- "AI_ENABLED",
- "FEATURE_AI_SUMMARY",
- "FEATURE_AI_AUTOLABELS",
- "FEATURE_MAILBOX_ADMIN_CHANNELS",
- "SCHEMA_CUSTOM_ATTRIBUTES_USER",
- "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN",
- "MAX_OUTGOING_ATTACHMENT_SIZE",
- "MAX_OUTGOING_BODY_SIZE",
- "MAX_INCOMING_EMAIL_SIZE",
- "MAX_RECIPIENTS_PER_MESSAGE",
- "MAX_TEMPLATE_IMAGE_SIZE",
- "IMAGE_PROXY_ENABLED",
- "FEATURE_MAILDOMAIN_CREATE",
- "FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
- "FEATURE_THREAD_SPLIT",
- "FEATURE_MAILDOMAIN_MANAGE_TOTP",
- "MESSAGES_MANUAL_RETRY_MAX_AGE",
- "FRONTEND_SILENT_LOGIN_ENABLED",
+ entry.key for entry in CONFIG_ENTRIES if entry.required
],
},
)
@@ -185,55 +252,10 @@ class ConfigView(drf.views.APIView):
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
- array_settings = [
- "ENVIRONMENT",
- "LANGUAGES",
- "LANGUAGE_CODE",
- "SCHEMA_CUSTOM_ATTRIBUTES_USER",
- "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN",
- "MAX_TEMPLATE_IMAGE_SIZE",
- "IMAGE_PROXY_ENABLED",
- "MESSAGES_MANUAL_RETRY_MAX_AGE",
- "FEATURE_MAILBOX_ADMIN_CHANNELS",
- "FEATURE_MAILDOMAIN_CREATE",
- "FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
- "FEATURE_THREAD_SPLIT",
- "MAX_OUTGOING_ATTACHMENT_SIZE",
- "MAX_OUTGOING_BODY_SIZE",
- "MAX_INCOMING_EMAIL_SIZE",
- "MAX_RECIPIENTS_PER_MESSAGE",
- "FRONTEND_SILENT_LOGIN_ENABLED",
- ]
- dict_settings = {}
- for setting in array_settings:
- if hasattr(settings, setting):
- dict_settings[setting] = getattr(settings, setting)
-
- # Expose the *effective* mandatory-TOTP capability rather than the raw
- # flag: the feature also requires IDENTITY_PROVIDER == "keycloak" and a
- # populated KEYCLOAK_TOTP_ROLE_ID. Surfacing the raw flag would let the
- # frontend render TOTP affordances that the backend silently refuses.
- dict_settings["FEATURE_MAILDOMAIN_MANAGE_TOTP"] = is_mandatory_totp_enabled()
-
- # AI Features
- dict_settings["AI_ENABLED"] = is_ai_enabled()
- dict_settings["FEATURE_AI_SUMMARY"] = is_ai_summary_enabled()
- dict_settings["FEATURE_AI_AUTOLABELS"] = is_auto_labels_enabled()
-
- # Drive service
- if base_url := settings.DRIVE_CONFIG.get("base_url"):
- dict_settings.update(
- {
- "DRIVE": {
- "sdk_url": f"{base_url}{settings.DRIVE_CONFIG.get('sdk_url')}",
- "api_url": f"{base_url}{settings.DRIVE_CONFIG.get('api_url')}",
- "file_url": f"{base_url}{settings.DRIVE_CONFIG.get('file_url')}",
- "preview_url": (
- f"{base_url}{settings.DRIVE_CONFIG.get('preview_url')}"
- ),
- "app_name": settings.DRIVE_CONFIG.get("app_name"),
- }
- }
- )
-
- return drf.response.Response(dict_settings)
+ return drf.response.Response(
+ {
+ entry.key: value
+ for entry in CONFIG_ENTRIES
+ if (value := entry.resolve()) is not None or entry.required
+ }
+ )
diff --git a/src/backend/core/mda/inbound_tasks.py b/src/backend/core/mda/inbound_tasks.py
index 462348a6..80d5ec71 100644
--- a/src/backend/core/mda/inbound_tasks.py
+++ b/src/backend/core/mda/inbound_tasks.py
@@ -194,9 +194,7 @@ def _retry_or_abandon(
# skips it instead of deleting and losing the only copy of the mail.
inbound_message.error_message = reason
inbound_message.abandoned_at = timezone.now()
- inbound_message.save(
- update_fields=["error_message", "abandoned_at", "updated_at"]
- )
+ inbound_message.save(update_fields=["error_message", "abandoned_at", "updated_at"])
return {
"success": False,
"inbound_message_id": str(inbound_message.id),
diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py
index 607c6e1b..f5ef05bd 100644
--- a/src/backend/core/tests/api/test_config.py
+++ b/src/backend/core/tests/api/test_config.py
@@ -31,12 +31,12 @@ pytestmark = pytest.mark.django_db
DRIVE_CONFIG={"base_url": None, "app_name": "Drive"},
MAX_OUTGOING_ATTACHMENT_SIZE=20971520, # 20MB
MAX_OUTGOING_BODY_SIZE=5242880, # 5MB
- MAX_INCOMING_EMAIL_SIZE=10485760, # 10MB
MAX_RECIPIENTS_PER_MESSAGE=42,
MAX_TEMPLATE_IMAGE_SIZE=2097152, # 2MB
IMAGE_PROXY_ENABLED=False,
MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds
FRONTEND_SILENT_LOGIN_ENABLED=True,
+ RELEASE="1.2.3",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
@@ -51,6 +51,7 @@ def test_api_config(is_authenticated):
assert response.status_code == HTTP_200_OK
assert response.json() == {
"ENVIRONMENT": "test",
+ "RELEASE": "1.2.3",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"AI_ENABLED": False,
@@ -63,15 +64,25 @@ def test_api_config(is_authenticated):
"FEATURE_MAILDOMAIN_MANAGE_TOTP": False,
"SCHEMA_CUSTOM_ATTRIBUTES_USER": {},
"SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": {},
- "MAX_INCOMING_EMAIL_SIZE": 10485760,
"MAX_OUTGOING_ATTACHMENT_SIZE": 20971520,
- "MAX_OUTGOING_BODY_SIZE": 5242880,
"MAX_RECIPIENTS_PER_MESSAGE": 42,
"MAX_TEMPLATE_IMAGE_SIZE": 2097152,
"IMAGE_PROXY_ENABLED": False,
"MESSAGES_MANUAL_RETRY_MAX_AGE": 86400,
"FRONTEND_SILENT_LOGIN_ENABLED": True,
}
+ # Optional settings left unconfigured must be omitted, not null nor
+ # defaulted: the frontend falls back on its deprecated NEXT_PUBLIC_*
+ # variables when a key is absent, so sending a backend default here
+ # would silently override them.
+ assert "SENTRY_DSN" not in response.json()
+ assert "DRIVE" not in response.json()
+ assert "FRONTEND_THEME_CONFIG" not in response.json()
+ assert "FRONTEND_FORCED_DEFAULT_LANGUAGE" not in response.json()
+ assert "FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB" not in response.json()
+ assert "FRONTEND_HELP_CENTER_URL" not in response.json()
+ assert "FRONTEND_FEEDBACK_WIDGET_CONFIG" not in response.json()
+ assert "FRONTEND_LAGAUFRE_WIDGET_CONFIG" not in response.json()
@override_settings(
@@ -99,6 +110,44 @@ def test_api_config_with_external_services():
}
+@override_settings(
+ SENTRY_DSN="https://public@sentry.example.com/1",
+ FRONTEND_THEME_CONFIG={"theme": "dsfr", "terms_of_service_url": "https://tos"},
+ FRONTEND_FORCED_DEFAULT_LANGUAGE=False,
+ FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB=50,
+ FRONTEND_HELP_CENTER_URL="https://help.example.com",
+ FRONTEND_FEEDBACK_WIDGET_CONFIG={
+ "api_url": "https://feedback.example.com",
+ "path": "https://feedback.example.com/static/",
+ "channel": "support",
+ "home_channel": "home",
+ },
+ FRONTEND_LAGAUFRE_WIDGET_CONFIG={
+ "api_url": "https://lagaufre.example.com",
+ "path": "https://lagaufre.example.com/static/",
+ },
+)
+def test_api_config_frontend_settings():
+ """Frontend settings configured on the backend should be exposed as-is."""
+ response = APIClient().get("/api/v1.0/config/")
+ assert response.status_code == HTTP_200_OK
+ config = response.json()
+ assert config["SENTRY_DSN"] == "https://public@sentry.example.com/1"
+ assert config["FRONTEND_THEME_CONFIG"] == {
+ "theme": "dsfr",
+ "terms_of_service_url": "https://tos",
+ }
+ # An explicit False must be sent: the backend value takes precedence
+ # over the frontend fallbacks, even when it equals their default.
+ assert config["FRONTEND_FORCED_DEFAULT_LANGUAGE"] is False
+ assert config["FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB"] == 50
+ assert config["FRONTEND_HELP_CENTER_URL"] == "https://help.example.com"
+ assert config["FRONTEND_FEEDBACK_WIDGET_CONFIG"]["channel"] == "support"
+ assert config["FRONTEND_LAGAUFRE_WIDGET_CONFIG"]["api_url"] == (
+ "https://lagaufre.example.com"
+ )
+
+
@override_settings(
FEATURE_MAILDOMAIN_MANAGE_TOTP=True,
KEYCLOAK_TOTP_ROLE_ID=None,
diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py
index ed48daa6..f84213d1 100644
--- a/src/backend/core/utils.py
+++ b/src/backend/core/utils.py
@@ -224,6 +224,27 @@ class JSONValue(values.Value):
raise ImproperlyConfigured(f"{name} is not valid JSON") from None
+class OptionalBooleanValue(values.BooleanValue):
+ """
+ A BooleanValue that also accepts ``None`` as default, to distinguish a
+ setting deliberately left unconfigured from an explicit True/False.
+ """
+
+ def __init__(self, *args, **kwargs):
+ default = kwargs.get("default", args[0] if args else None)
+ if default is None:
+ # BooleanValue's __init__ rejects a None default: initialize
+ # with a placeholder then restore None afterwards.
+ if args:
+ args = (False, *args[1:])
+ else:
+ kwargs["default"] = False
+ super().__init__(*args, **kwargs)
+ self.default = None
+ else:
+ super().__init__(*args, **kwargs)
+
+
class ThrottleRateValue(values.Value):
"""
A custom value class that parses and validates throttle rate strings
diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py
index 422cbbb0..7f697e7e 100644
--- a/src/backend/messages/settings.py
+++ b/src/backend/messages/settings.py
@@ -21,7 +21,7 @@ import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
-from core.utils import JSONValue, ThrottleRateValue
+from core.utils import JSONValue, OptionalBooleanValue, ThrottleRateValue
logger = logging.getLogger(__name__)
@@ -752,8 +752,8 @@ class Base(Configuration):
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
- ("fr-fr", "French"),
- ("nl-nl", "Dutch"),
+ ("fr-fr", "Français"),
+ ("nl-nl", "Nederlands"),
)
)
@@ -909,12 +909,37 @@ class Base(Configuration):
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Frontend
- FRONTEND_THEME = values.Value(
- None, environ_name="FRONTEND_THEME", environ_prefix=None
- )
+ # These settings default to None ("not configured") on purpose: the config
+ # endpoint omits unset entries so the frontend can still fall back on its
+ # deprecated NEXT_PUBLIC_* build-time variables during the migration.
+ # Sending backend defaults instead would silently override those variables.
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
)
+ FRONTEND_THEME_CONFIG = JSONValue(
+ None, environ_name="FRONTEND_THEME_CONFIG", environ_prefix=None
+ )
+ FRONTEND_FORCED_DEFAULT_LANGUAGE = OptionalBooleanValue(
+ None,
+ environ_name="FRONTEND_FORCED_DEFAULT_LANGUAGE",
+ environ_prefix=None,
+ )
+ FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB = values.PositiveIntegerValue(
+ None,
+ environ_name="FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB",
+ environ_prefix=None,
+ )
+ FRONTEND_HELP_CENTER_URL = values.Value(
+ None, environ_name="FRONTEND_HELP_CENTER_URL", environ_prefix=None
+ )
+ # Expected keys: api_url, path, channel, home_channel
+ FRONTEND_FEEDBACK_WIDGET_CONFIG = JSONValue(
+ None, environ_name="FRONTEND_FEEDBACK_WIDGET_CONFIG", environ_prefix=None
+ )
+ # Expected keys: api_url, path
+ FRONTEND_LAGAUFRE_WIDGET_CONFIG = JSONValue(
+ None, environ_name="FRONTEND_LAGAUFRE_WIDGET_CONFIG", environ_prefix=None
+ )
# Celery
CELERY_BROKER_URL = values.Value(
@@ -1308,7 +1333,7 @@ class Base(Configuration):
f"Invalid MTA_OUT_SMTP_TLS_SECURITY_LEVEL: {self.MTA_OUT_SMTP_TLS_SECURITY_LEVEL}"
)
- # OIDC Deprecated fields mapping
+ # Deprecated fields mapping
deprecated_settings_mapping = (
(
"USER_OIDC_ESSENTIAL_CLAIMS",
@@ -1321,6 +1346,9 @@ class Base(Configuration):
values.ListValue(),
),
("USER_OIDC_FIELD_TO_SHORTNAME", None, None),
+ # FRONTEND_THEME was never honored; FRONTEND_THEME_CONFIG is the
+ # intentional replacement, so the old value is not mapped over.
+ ("FRONTEND_THEME", None, None),
)
for deprecated_setting, new_setting, Parser in deprecated_settings_mapping:
diff --git a/src/frontend/i18next.config.ts b/src/frontend/i18next.config.ts
index 6d2d0c8c..c256b75d 100644
--- a/src/frontend/i18next.config.ts
+++ b/src/frontend/i18next.config.ts
@@ -1,8 +1,8 @@
import { defineConfig } from 'i18next-cli';
-import { LANGUAGES_ALLOWED } from './src/features/i18n/conf';
+import { SUPPORTED_LOCALES } from './src/features/i18n/conf';
export default defineConfig({
- locales: LANGUAGES_ALLOWED,
+ locales: SUPPORTED_LOCALES,
extract: {
defaultNS: "common",
input: ['src/**/*.{js,jsx,ts,tsx}'],
diff --git a/src/frontend/instrumentation-client.ts b/src/frontend/instrumentation-client.ts
deleted file mode 100644
index 887dfe8a..00000000
--- a/src/frontend/instrumentation-client.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import * as Sentry from "@sentry/react";
-
-const isSentryEnabled = import.meta.env.NEXT_PUBLIC_SENTRY_DSN && import.meta.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT;
-
-if (isSentryEnabled) {
- Sentry.init({
- dsn: import.meta.env.NEXT_PUBLIC_SENTRY_DSN,
- environment: import.meta.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
- });
- Sentry.setTag("application", "frontend");
-}
diff --git a/src/frontend/src/bootstrap.tsx b/src/frontend/src/bootstrap.tsx
new file mode 100644
index 00000000..1cdfa9a8
--- /dev/null
+++ b/src/frontend/src/bootstrap.tsx
@@ -0,0 +1,67 @@
+import { createRoot } from "react-dom/client";
+import { createRouter, parseSearchWith, RouterProvider, stringifySearchWith } from "@tanstack/react-router";
+
+import { routeTree } from "./routes.gen";
+import { configRetrieve, configRetrieveResponse, getConfigRetrieveQueryKey } from "@/features/api/gen";
+import { queryClient } from "@/features/api/query-client";
+import { resolveConfig } from "@/features/config/resolve";
+import { initI18n } from "@/features/i18n/initI18n";
+import { installThemeFavicons } from "@/features/providers/theme-favicons";
+import { initSentry } from "@/features/sentry";
+import { handle } from '@/features/utils/errors';
+
+// Default TSR encoding JSON-wraps every search value (`?key=1` → `?key=%221%22`).
+// The rest of the app builds URLs via `URLSearchParams.toString()` and the
+// backend expects plain values, so we plug identity parsers to keep both sides
+// aligned — values stay as raw strings on the way out and on the way back.
+const router = createRouter({
+ routeTree,
+ scrollRestoration: false,
+ defaultPreload: false,
+ parseSearch: parseSearchWith((value) => value),
+ stringifySearch: stringifySearchWith((value) => (value == null ? "" : String(value))),
+});
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: typeof router;
+ }
+}
+
+/**
+ * Fetch the backend configuration then initialize everything that must be
+ * ready before the first React render: Sentry, i18n and the theme favicons.
+ * The response also primes the React Query cache so `ConfigProvider` reads
+ * it without a second fetch.
+ */
+export const bootstrap = async () => {
+ const container = document.getElementById("root");
+ if (!container) throw new Error("#root element not found in index.html");
+
+ try {
+ let response: configRetrieveResponse | undefined;
+ try {
+ response = await configRetrieve();
+ } catch (error) {
+ // The app still boots on deprecated env vars and hardcoded defaults.
+ // The cache is intentionally left unprimed so ConfigProvider retries
+ // the fetch on mount and the React tree self-heals if the API is back.
+ console.error("[config] Failed to fetch the configuration, falling back to build-time defaults.", error);
+ }
+
+ const config = resolveConfig(response?.data);
+ initSentry(config);
+ initI18n(config);
+ installThemeFavicons(config.THEME_CONFIG.theme);
+ if (response) {
+ queryClient.setQueryData(getConfigRetrieveQueryKey(), response);
+ }
+
+ createRoot(container).render(
Something went wrong while starting the application. Please try again later.
"; + } +}; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 7e8bf9b9..35db8c87 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -8,10 +8,16 @@ import type { ConfigRetrieve200DRIVE } from "./config_retrieve200_driv_e"; import type { ConfigRetrieve200SCHEMACUSTOMATTRIBUTESUSER } from "./config_retrieve200_schemacustomattributesuse_r"; import type { ConfigRetrieve200SCHEMACUSTOMATTRIBUTESMAILDOMAIN } from "./config_retrieve200_schemacustomattributesmaildomai_n"; +import type { ConfigRetrieve200FRONTENDTHEMECONFIG } from "./config_retrieve200_frontendthemeconfi_g"; +import type { ConfigRetrieve200FRONTENDFEEDBACKWIDGETCONFIG } from "./config_retrieve200_frontendfeedbackwidgetconfi_g"; +import type { ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG } from "./config_retrieve200_frontendlagaufrewidgetconfi_g"; export type ConfigRetrieve200 = { readonly ENVIRONMENT: string; - readonly LANGUAGES: readonly string[]; + /** Version of the application */ + readonly RELEASE: string; + /** Available languages, as (code, label) pairs */ + readonly LANGUAGES: readonly string[][]; readonly LANGUAGE_CODE: string; readonly AI_ENABLED: boolean; readonly FEATURE_AI_SUMMARY: boolean; @@ -23,10 +29,6 @@ export type ConfigRetrieve200 = { readonly SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: ConfigRetrieve200SCHEMACUSTOMATTRIBUTESMAILDOMAIN; /** Maximum size in bytes for outgoing email attachments */ readonly MAX_OUTGOING_ATTACHMENT_SIZE: number; - /** Maximum size in bytes for outgoing email body (text + HTML) */ - readonly MAX_OUTGOING_BODY_SIZE: number; - /** Maximum size in bytes for incoming email (including attachments and body) */ - readonly MAX_INCOMING_EMAIL_SIZE: number; /** Maximum number of recipients per message (to + cc + bcc) */ readonly MAX_RECIPIENTS_PER_MESSAGE: number; /** Maximum size in bytes for images embedded in templates and signatures */ @@ -41,4 +43,18 @@ export type ConfigRetrieve200 = { readonly MESSAGES_MANUAL_RETRY_MAX_AGE: number; /** Whether silent OIDC login is enabled */ readonly FRONTEND_SILENT_LOGIN_ENABLED: boolean; + /** Sentry DSN shared with the frontend */ + readonly SENTRY_DSN?: string; + /** Theme configuration for the frontend (theme, terms_of_service_url, footer) */ + readonly FRONTEND_THEME_CONFIG?: ConfigRetrieve200FRONTENDTHEMECONFIG; + /** Whether the frontend should fall back to LANGUAGE_CODE instead of the browser language */ + readonly FRONTEND_FORCED_DEFAULT_LANGUAGE?: boolean; + /** Chunk size in MB for frontend multipart uploads */ + readonly FRONTEND_MULTIPART_UPLOAD_CHUNK_SIZE_MB?: number; + /** Help center URL */ + readonly FRONTEND_HELP_CENTER_URL?: string; + /** Configuration of the feedback widget */ + readonly FRONTEND_FEEDBACK_WIDGET_CONFIG?: ConfigRetrieve200FRONTENDFEEDBACKWIDGETCONFIG; + /** Configuration of the Lagaufre widget */ + readonly FRONTEND_LAGAUFRE_WIDGET_CONFIG?: ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG; }; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200_frontendfeedbackwidgetconfi_g.ts b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendfeedbackwidgetconfi_g.ts new file mode 100644 index 00000000..0bf8fdd3 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendfeedbackwidgetconfi_g.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Configuration of the feedback widget + */ +export type ConfigRetrieve200FRONTENDFEEDBACKWIDGETCONFIG = { + readonly api_url?: string; + readonly path?: string; + readonly channel?: string; + readonly home_channel?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200_frontendlagaufrewidgetconfi_g.ts b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendlagaufrewidgetconfi_g.ts new file mode 100644 index 00000000..228b2d51 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendlagaufrewidgetconfi_g.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Configuration of the Lagaufre widget + */ +export type ConfigRetrieve200FRONTENDLAGAUFREWIDGETCONFIG = { + readonly api_url?: string; + readonly path?: string; +}; diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200_frontendthemeconfi_g.ts b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendthemeconfi_g.ts new file mode 100644 index 00000000..3c5b956b --- /dev/null +++ b/src/frontend/src/features/api/gen/models/config_retrieve200_frontendthemeconfi_g.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Theme configuration for the frontend (theme, terms_of_service_url, footer) + */ +export type ConfigRetrieve200FRONTENDTHEMECONFIG = { [key: string]: unknown }; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index ee07f95c..15965eef 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -25,6 +25,9 @@ export * from "./channel_create_response"; export * from "./channel_request"; export * from "./config_retrieve200"; export * from "./config_retrieve200_driv_e"; +export * from "./config_retrieve200_frontendfeedbackwidgetconfi_g"; +export * from "./config_retrieve200_frontendlagaufrewidgetconfi_g"; +export * from "./config_retrieve200_frontendthemeconfi_g"; export * from "./config_retrieve200_schemacustomattributesmaildomai_n"; export * from "./config_retrieve200_schemacustomattributesuse_r"; export * from "./contact"; diff --git a/src/frontend/src/features/api/query-client.tsx b/src/frontend/src/features/api/query-client.tsx new file mode 100644 index 00000000..3bd3e0f0 --- /dev/null +++ b/src/frontend/src/features/api/query-client.tsx @@ -0,0 +1,38 @@ +import { + MutationCache, + Query, + QueryCache, + QueryClient, +} from "@tanstack/react-query"; + +import { addToast, ToasterItem } from "@/features/ui/components/toaster"; +import { errorToString } from "@/features/api/api-error"; + +const onError = (error: Error, query: unknown) => { + if ((query as Query).meta?.noGlobalError) { + return; + } + addToast( +