From e6296f9b3af711657ced836addc68ee95ba757f2 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sun, 25 Jan 2026 17:29:19 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(integrations)=20add=20integrations=20?= =?UTF-8?q?view=20in=20mailbox=20(#488)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For now this allows users to create feedback widgets linked to their mailbox. We will also have API keys and recurring imports there. --- docs/env.md | 1 + src/backend/core/api/openapi.json | 421 +++++++++ src/backend/core/api/serializers.py | 79 +- src/backend/core/api/viewsets/channel.py | 103 ++ src/backend/core/api/viewsets/config.py | 11 + .../core/api/viewsets/inbound/widget.py | 15 +- src/backend/core/mda/inbound_create.py | 21 +- src/backend/core/tests/api/test_channels.py | 423 +++++++++ src/backend/core/tests/api/test_config.py | 2 + .../core/tests/api/test_inbound_widget.py | 26 +- src/backend/core/urls.py | 16 + src/backend/messages/settings.py | 3 + src/frontend/public/locales/common/en-US.json | 45 + src/frontend/public/locales/common/fr-FR.json | 47 +- .../src/features/api/gen/channels/channels.ts | 891 ++++++++++++++++++ src/frontend/src/features/api/gen/index.ts | 1 + .../src/features/api/gen/models/channel.ts | 41 + .../api/gen/models/channel_request.ts | 27 + .../api/gen/models/config_retrieve200.ts | 1 + .../src/features/api/gen/models/index.ts | 3 + .../api/gen/models/patched_channel_request.ts | 27 + .../create-integration-action.tsx | 25 + .../integrations-data-grid.tsx | 179 ++++ .../integrations-view/page-content.tsx | 16 + .../modal-compose-integration/_index.scss | 263 ++++++ .../modal-compose-integration/index.tsx | 193 ++++ .../tags-selector.tsx | 219 +++++ .../widget-integration-form.tsx | 245 +++++ .../components/main/header/authenticated.tsx | 11 + .../src/features/providers/config.tsx | 1 + src/frontend/src/hooks/use-feature.ts | 3 + .../[mailboxId]/integrations/index.tsx | 52 + src/frontend/src/styles/main.scss | 1 + 33 files changed, 3401 insertions(+), 11 deletions(-) create mode 100644 src/backend/core/api/viewsets/channel.py create mode 100644 src/backend/core/tests/api/test_channels.py create mode 100644 src/frontend/src/features/api/gen/channels/channels.ts create mode 100644 src/frontend/src/features/api/gen/models/channel.ts create mode 100644 src/frontend/src/features/api/gen/models/channel_request.ts create mode 100644 src/frontend/src/features/api/gen/models/patched_channel_request.ts create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/page-content.tsx create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/tags-selector.tsx create mode 100644 src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx create mode 100644 src/frontend/src/pages/mailbox/[mailboxId]/integrations/index.tsx diff --git a/docs/env.md b/docs/env.md index c1ea4d19..b4144c29 100644 --- a/docs/env.md +++ b/docs/env.md @@ -286,6 +286,7 @@ _Those settings are deprecated and will be removed in the future._ | `AI_MODEL` | None | Default model used for AI features | Optional | | `FEATURE_AI_SUMMARY` | `False` | Default enabled mode for summary AI features | Required | | `FEATURE_AI_AUTOLABELS` | `False` | Default enabled mode for label AI features | Required | +| `FEATURE_MAILBOX_ADMIN_CHANNELS` | `` | Comma-separated list of channel types enabled for mailbox admin (e.g., `widget,api_key`). Empty list disables all channel types. | Optional | ### Image Proxy diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 25465d0e..6c06b7e8 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -171,6 +171,13 @@ "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.", @@ -241,6 +248,7 @@ "AI_ENABLED", "FEATURE_AI_SUMMARY", "FEATURE_AI_AUTOLABELS", + "FEATURE_MAILBOX_ADMIN_CHANNELS", "SCHEMA_CUSTOM_ATTRIBUTES_USER", "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", "MAX_OUTGOING_ATTACHMENT_SIZE", @@ -2424,6 +2432,313 @@ } } }, + "/api/v1.0/mailboxes/{mailbox_id}/channels/": { + "get": { + "operationId": "mailboxes_channels_list", + "description": "Manage integration channels for a mailbox", + "parameters": [ + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Channel" + } + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "mailboxes_channels_create", + "description": "Manage integration channels for a mailbox", + "parameters": [ + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "Channel created successfully" + }, + "400": { + "description": "Invalid input data" + }, + "403": { + "description": "Permission denied" + } + } + } + }, + "/api/v1.0/mailboxes/{mailbox_id}/channels/{id}/": { + "get": { + "operationId": "mailboxes_channels_retrieve", + "description": "Manage integration channels for a mailbox", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "mailboxes_channels_update", + "description": "Manage integration channels for a mailbox", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ChannelRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "Channel updated successfully" + }, + "400": { + "description": "Invalid input data" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Channel not found" + } + } + }, + "patch": { + "operationId": "mailboxes_channels_partial_update", + "description": "Manage integration channels for a mailbox", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedChannelRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedChannelRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "mailboxes_channels_destroy", + "description": "Manage integration channels for a mailbox", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "mailbox_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "channels" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "204": { + "description": "Channel deleted successfully" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Channel not found" + } + } + } + }, "/api/v1.0/mailboxes/{mailbox_id}/image-proxy/": { "get": { "operationId": "mailboxes_image_proxy_list", @@ -5218,6 +5533,91 @@ "value" ] }, + "Channel": { + "type": "object", + "description": "Serialize Channel model.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "name": { + "type": "string", + "description": "Human-readable name for this channel", + "maxLength": 255 + }, + "type": { + "type": "string", + "description": "Type of channel", + "maxLength": 255 + }, + "settings": { + "description": "Channel-specific configuration settings" + }, + "mailbox": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "readOnly": true, + "nullable": true + }, + "maildomain": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "readOnly": true, + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Created on", + "description": "date and time at which a record was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Updated on", + "description": "date and time at which a record was last updated" + } + }, + "required": [ + "created_at", + "id", + "mailbox", + "maildomain", + "name", + "updated_at" + ] + }, + "ChannelRequest": { + "type": "object", + "description": "Serialize Channel model.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name for this channel", + "maxLength": 255 + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Type of channel", + "maxLength": 255 + }, + "settings": { + "description": "Channel-specific configuration settings" + } + }, + "required": [ + "name" + ] + }, "Contact": { "type": "object", "description": "Serialize contacts.", @@ -6920,6 +7320,27 @@ "size" ] }, + "PatchedChannelRequest": { + "type": "object", + "description": "Serialize Channel model.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name for this channel", + "maxLength": 255 + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Type of channel", + "maxLength": 255 + }, + "settings": { + "description": "Channel-specific configuration settings" + } + } + }, "PatchedLabelRequest": { "type": "object", "description": "Serializer for Label model.", diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 8630389b..015576ec 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -3,7 +3,9 @@ # pylint: disable=too-many-lines import json +import uuid +from django.conf import settings from django.db import transaction from django.db.models import Count, Exists, OuterRef, Q from django.utils.translation import gettext_lazy as _ @@ -1319,9 +1321,13 @@ class ImportIMAPSerializer(ImportBaseSerializer): ) -class ChannelSerializer(AbilitiesModelSerializer): +class ChannelSerializer(serializers.ModelSerializer): """Serialize Channel model.""" + # Explicitly mark nullable fields to fix OpenAPI schema + mailbox = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True) + maildomain = serializers.PrimaryKeyRelatedField(read_only=True, allow_null=True) + class Meta: model = models.Channel fields = [ @@ -1334,10 +1340,77 @@ class ChannelSerializer(AbilitiesModelSerializer): "created_at", "updated_at", ] - read_only_fields = ["id", "created_at", "updated_at"] + read_only_fields = ["id", "mailbox", "maildomain", "created_at", "updated_at"] + + def validate_settings(self, value): + """Validate settings, including tags if present.""" + if not value: + return value + + tags = value.get("tags", []) + if not tags: + return value + + # Get mailbox from context or instance + mailbox = self.context.get("mailbox") + if not mailbox and self.instance: + mailbox = self.instance.mailbox + + if not mailbox: + # Tags require a mailbox - can't use tags without one + raise serializers.ValidationError( + {"tags": "Tags can only be used when a mailbox is configured."} + ) + + # Validate each tag + invalid_tags = [] + missing_tags = [] + + for tag_id in tags: + # Validate UUID format + try: + tag_uuid = uuid.UUID(str(tag_id)) + except (ValueError, TypeError): + invalid_tags.append(tag_id) + continue + + # Check if label exists in the mailbox + if not models.Label.objects.filter(id=tag_uuid, mailbox=mailbox).exists(): + missing_tags.append(tag_id) + + errors = [] + if invalid_tags: + errors.append(f"Invalid tag IDs (not valid UUIDs): {invalid_tags}") + if missing_tags: + errors.append(f"Tags not found in mailbox: {missing_tags}") + + if errors: + raise serializers.ValidationError({"tags": errors}) + + return value def validate(self, attrs): - """Validate channel data.""" + """Validate channel data. + + When used in the nested mailbox context (via ChannelViewSet), + the mailbox is set from context and doesn't need to be validated here. + """ + # If we have a mailbox in context (from ChannelViewSet), validate channel type + # and skip mailbox/maildomain validation. + # This allows Django admin to create any channel type. + if self.context.get("mailbox"): + channel_type = attrs.get("type") + if channel_type: + allowed_types = settings.FEATURE_MAILBOX_ADMIN_CHANNELS + if channel_type not in allowed_types: + raise serializers.ValidationError( + { + "type": f"Channel type '{channel_type}' is not authorized. " + f"Allowed types: {', '.join(allowed_types)}" + } + ) + return attrs + mailbox = attrs.get("mailbox") maildomain = attrs.get("maildomain") diff --git a/src/backend/core/api/viewsets/channel.py b/src/backend/core/api/viewsets/channel.py new file mode 100644 index 00000000..c3c764df --- /dev/null +++ b/src/backend/core/api/viewsets/channel.py @@ -0,0 +1,103 @@ +"""API ViewSet for Channel model.""" + +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property + +from drf_spectacular.utils import ( + OpenApiResponse, + extend_schema, +) +from rest_framework import mixins, status, viewsets +from rest_framework.response import Response + +from core import models + +from .. import permissions, serializers + + +@extend_schema( + tags=["channels"], description="Manage integration channels for a mailbox" +) +class ChannelViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, +): + """ViewSet for Channel model - allows mailbox admins to manage integration channels.""" + + serializer_class = serializers.ChannelSerializer + permission_classes = [permissions.IsMailboxAdmin] + pagination_class = None + lookup_field = "pk" + + @cached_property + def mailbox(self): + """Get mailbox from URL parameter.""" + return get_object_or_404(models.Mailbox, id=self.kwargs["mailbox_id"]) + + def get_queryset(self): + """Get channels for the mailbox the user has admin access to.""" + return models.Channel.objects.filter(mailbox=self.mailbox).order_by( + "-created_at" + ) + + def get_serializer_context(self): + """Add mailbox to serializer context.""" + context = super().get_serializer_context() + context["mailbox"] = self.mailbox + return context + + @extend_schema( + request=serializers.ChannelSerializer, + responses={ + 201: OpenApiResponse( + response=serializers.ChannelSerializer, + description="Channel created successfully", + ), + 400: OpenApiResponse(description="Invalid input data"), + 403: OpenApiResponse(description="Permission denied"), + }, + ) + def create(self, request, *args, **kwargs): + """Create a new channel for the mailbox.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(mailbox=self.mailbox) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + request=serializers.ChannelSerializer, + responses={ + 200: OpenApiResponse( + response=serializers.ChannelSerializer, + description="Channel updated successfully", + ), + 400: OpenApiResponse(description="Invalid input data"), + 403: OpenApiResponse(description="Permission denied"), + 404: OpenApiResponse(description="Channel not found"), + }, + ) + def update(self, request, *args, **kwargs): + """Update a channel.""" + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + @extend_schema( + responses={ + 204: OpenApiResponse(description="Channel deleted successfully"), + 403: OpenApiResponse(description="Permission denied"), + 404: OpenApiResponse(description="Channel not found"), + }, + ) + def destroy(self, request, *args, **kwargs): + """Delete a channel.""" + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index f82e79e4..fd7de2e6 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -38,6 +38,11 @@ class ConfigView(drf.views.APIView): "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.", @@ -109,6 +114,7 @@ class ConfigView(drf.views.APIView): "AI_ENABLED", "FEATURE_AI_SUMMARY", "FEATURE_AI_AUTOLABELS", + "FEATURE_MAILBOX_ADMIN_CHANNELS", "SCHEMA_CUSTOM_ATTRIBUTES_USER", "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", "MAX_OUTGOING_ATTACHMENT_SIZE", @@ -145,6 +151,11 @@ class ConfigView(drf.views.APIView): dict_settings["FEATURE_AI_SUMMARY"] = is_ai_summary_enabled() dict_settings["FEATURE_AI_AUTOLABELS"] = is_auto_labels_enabled() + # Feature flags - return as list + dict_settings["FEATURE_MAILBOX_ADMIN_CHANNELS"] = list( + settings.FEATURE_MAILBOX_ADMIN_CHANNELS + ) + # Email size limits dict_settings["MAX_OUTGOING_ATTACHMENT_SIZE"] = ( settings.MAX_OUTGOING_ATTACHMENT_SIZE diff --git a/src/backend/core/api/viewsets/inbound/widget.py b/src/backend/core/api/viewsets/inbound/widget.py index 56465309..c8261751 100644 --- a/src/backend/core/api/viewsets/inbound/widget.py +++ b/src/backend/core/api/viewsets/inbound/widget.py @@ -144,10 +144,23 @@ class InboundWidgetViewSet(viewsets.GenericViewSet): ), ) + # Build subject from template or use default + # Template can use {referer_domain} placeholder (same format as signature templates) + default_subject_template = "Message from {referer_domain}" + subject_template = (channel.settings or {}).get( + "subject_template", default_subject_template + ) + + # Replace template variables + subject = subject_template.replace("{referer_domain}", source_name) + + # Sanitize subject to prevent header injection (strip newlines/carriage returns) + subject = subject.replace("\r", "").replace("\n", "") + # Build a JMAP-like structured format that we could have got from parse_email_message() parsed_email = { - "subject": f"Message from {source_name}", + "subject": subject, "from": {"email": sender_email}, "to": [{"name": target_name, "email": target_email}], "date": timezone.now(), diff --git a/src/backend/core/mda/inbound_create.py b/src/backend/core/mda/inbound_create.py index 3596dceb..cf1825be 100644 --- a/src/backend/core/mda/inbound_create.py +++ b/src/backend/core/mda/inbound_create.py @@ -249,6 +249,20 @@ def _create_message_from_inbound( logger.exception("Error creating label %s: %s", label, e) continue + # Apply labels from channel settings (e.g., widget channel tags) + if channel and channel.settings: + channel_tags = channel.settings.get("tags", []) + for tag_id in channel_tags: + try: + label_obj = models.Label.objects.get(id=tag_id, mailbox=mailbox) + thread.labels.add(label_obj) + except models.Label.DoesNotExist: + logger.warning( + "Label %s not found for channel %s, skipping", tag_id, channel.id + ) + except Exception as e: + logger.exception("Error adding label %s from channel: %s", tag_id, e) + # --- 4. Get or Create Sender Contact --- # sender_info = parsed_email.get("from", {}) sender_email = sender_info.get("email") @@ -502,8 +516,11 @@ def _create_message_from_inbound( thread.summary = new_summary thread.save(update_fields=["summary"]) - # Assign labels to the thread - if is_auto_labels_enabled(): + # Assign labels to the thread (skip if channel already applied tags) + has_channel_tags = ( + channel and channel.settings and channel.settings.get("tags") + ) + if is_auto_labels_enabled() and not has_channel_tags: assign_label_to_thread(thread, mailbox.id) except Exception as e: diff --git a/src/backend/core/tests/api/test_channels.py b/src/backend/core/tests/api/test_channels.py new file mode 100644 index 00000000..6c3bb4cb --- /dev/null +++ b/src/backend/core/tests/api/test_channels.py @@ -0,0 +1,423 @@ +"""Tests for the channel API endpoints.""" + +# pylint: disable=redefined-outer-name, unused-argument, too-many-public-methods + +import uuid + +from django.test import override_settings +from django.urls import reverse + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import models +from core.factories import ( + ChannelFactory, + LabelFactory, + MailboxFactory, + MailDomainAccessFactory, + MailDomainFactory, + UserFactory, +) + + +@pytest.fixture +def user(): + """Create a test user.""" + return UserFactory() + + +@pytest.fixture +def mailbox(user): + """Create a test mailbox with admin access for the user.""" + mailbox = MailboxFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.ADMIN) + return mailbox + + +@pytest.fixture +def api_client(user): + """Create an authenticated API client.""" + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def channel(mailbox): + """Create a test channel.""" + return ChannelFactory(mailbox=mailbox, type="widget") + + +@pytest.mark.django_db +class TestChannelList: + """Test the channel list endpoint.""" + + def test_list_channels(self, api_client, mailbox, channel): + """Test listing channels for a mailbox.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["id"] == str(channel.id) + assert response.data[0]["name"] == channel.name + assert response.data[0]["type"] == "widget" + + def test_list_channels_empty(self, api_client, mailbox): + """Test listing channels when none exist.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 0 + + def test_list_channels_no_access(self, api_client): + """Test listing channels for a mailbox the user has no access to.""" + other_mailbox = MailboxFactory() + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": other_mailbox.id}) + response = api_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_list_channels_viewer_access(self, api_client, user): + """Test listing channels with viewer role (should fail - admin required).""" + mailbox = MailboxFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER) + ChannelFactory(mailbox=mailbox) + + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestChannelCreate: + """Test the channel creation endpoint.""" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_widget_channel(self, api_client, mailbox): + """Test creating a widget channel.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = { + "name": "My Widget", + "type": "widget", + "settings": { + "subject_template": "New inquiry from {referer_domain}", + "config": {"enabled": True}, + }, + } + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == "My Widget" + assert response.data["type"] == "widget" + assert ( + response.data["settings"]["subject_template"] + == "New inquiry from {referer_domain}" + ) + assert str(response.data["mailbox"]) == str(mailbox.id) + + # Verify in database + channel = models.Channel.objects.get(id=response.data["id"]) + assert channel.mailbox == mailbox + assert channel.type == "widget" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_with_tags(self, api_client, mailbox): + """Test creating a widget channel with tags.""" + label = LabelFactory(mailbox=mailbox, name="Widget Inquiries") + + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = { + "name": "My Widget with Tags", + "type": "widget", + "settings": { + "subject_template": "Message from {referer_domain}", + "tags": [str(label.id)], + }, + } + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert str(label.id) in response.data["settings"]["tags"] + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_with_invalid_tag_uuid(self, api_client, mailbox): + """Test creating a channel with an invalid tag UUID fails.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = { + "name": "Widget with Invalid Tags", + "type": "widget", + "settings": { + "tags": ["not-a-valid-uuid", "also-invalid"], + }, + } + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "settings" in response.data + assert "tags" in response.data["settings"] + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_with_nonexistent_tag(self, api_client, mailbox): + """Test creating a channel with a tag that doesn't exist fails.""" + nonexistent_id = str(uuid.uuid4()) + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = { + "name": "Widget with Missing Tags", + "type": "widget", + "settings": { + "tags": [nonexistent_id], + }, + } + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "settings" in response.data + assert "tags" in response.data["settings"] + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_with_tag_from_other_mailbox(self, api_client, mailbox): + """Test creating a channel with a tag from another mailbox fails.""" + other_mailbox = MailboxFactory() + other_label = LabelFactory(mailbox=other_mailbox, name="Other Label") + + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = { + "name": "Widget with Wrong Mailbox Tag", + "type": "widget", + "settings": { + "tags": [str(other_label.id)], + }, + } + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "settings" in response.data + assert "tags" in response.data["settings"] + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_no_access(self, api_client): + """Test creating a channel for a mailbox the user has no access to.""" + other_mailbox = MailboxFactory() + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": other_mailbox.id}) + data = {"name": "Test", "type": "widget", "settings": {}} + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_viewer_access(self, api_client, user): + """Test creating a channel with viewer role (should fail).""" + mailbox = MailboxFactory() + mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER) + + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = {"name": "Test", "type": "widget", "settings": {}} + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_create_channel_unauthorized_type(self, api_client, mailbox): + """Test creating a channel with an unauthorized type.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = {"name": "Test API Key", "type": "api_key", "settings": {}} + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "type" in response.data + assert "not authorized" in str(response.data["type"]).lower() + assert "api_key" in str(response.data["type"]) + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget", "api_key"]) + def test_create_channel_authorized_type(self, api_client, mailbox): + """Test creating a channel with an authorized type.""" + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = {"name": "Test Widget", "type": "widget", "settings": {}} + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["type"] == "widget" + + +@pytest.mark.django_db +class TestChannelRetrieve: + """Test the channel retrieve endpoint.""" + + def test_retrieve_channel(self, api_client, mailbox, channel): + """Test retrieving a specific channel.""" + url = reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == str(channel.id) + assert response.data["name"] == channel.name + + def test_retrieve_channel_not_found(self, api_client, mailbox): + """Test retrieving a non-existent channel.""" + url = reverse( + "mailbox-channels-detail", + kwargs={ + "mailbox_id": mailbox.id, + "pk": "00000000-0000-0000-0000-000000000000", + }, + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +class TestChannelUpdate: + """Test the channel update endpoint.""" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_update_channel(self, api_client, mailbox, channel): + """Test updating a channel.""" + url = reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + data = { + "name": "Updated Widget Name", + "type": "widget", + "settings": { + "subject_template": "Updated subject from {referer_domain}", + }, + } + + response = api_client.put(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Updated Widget Name" + assert ( + response.data["settings"]["subject_template"] + == "Updated subject from {referer_domain}" + ) + + # Verify in database + channel.refresh_from_db() + assert channel.name == "Updated Widget Name" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_partial_update_channel(self, api_client, mailbox, channel): + """Test partially updating a channel.""" + url = reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + data = {"name": "Partially Updated Name"} + + response = api_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Partially Updated Name" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_update_channel_no_access(self, api_client, mailbox, channel): + """Test updating a channel for a mailbox the user has no admin access to.""" + # Remove admin access + mailbox.accesses.all().delete() + + url = reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + data = {"name": "Should Not Update"} + + response = api_client.put(url, data, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestChannelDelete: + """Test the channel deletion endpoint.""" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_delete_channel(self, api_client, mailbox, channel): + """Test deleting a channel.""" + channel_id = channel.id + url = reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + + response = api_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not models.Channel.objects.filter(id=channel_id).exists() + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_delete_channel_no_access(self, api_client, mailbox, channel): + """Test deleting a channel without admin access.""" + # Remove admin access + mailbox.accesses.all().delete() + + url = reverse( + "mailbox-channels-detail", + kwargs={"mailbox_id": mailbox.id, "pk": channel.id}, + ) + + response = api_client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestChannelDomainAdminAccess: + """Test that domain admins can also manage channels.""" + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_domain_admin_can_list_channels(self, api_client, user): + """Test that domain admin can list channels.""" + domain = MailDomainFactory() + MailDomainAccessFactory( + maildomain=domain, + user=user, + role=models.MailDomainAccessRoleChoices.ADMIN, + ) + mailbox = MailboxFactory(domain=domain) + channel = ChannelFactory(mailbox=mailbox) + + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["id"] == str(channel.id) + + @override_settings(FEATURE_MAILBOX_ADMIN_CHANNELS=["widget"]) + def test_domain_admin_can_create_channel(self, api_client, user): + """Test that domain admin can create a channel.""" + domain = MailDomainFactory() + MailDomainAccessFactory( + maildomain=domain, + user=user, + role=models.MailDomainAccessRoleChoices.ADMIN, + ) + mailbox = MailboxFactory(domain=domain) + + url = reverse("mailbox-channels-list", kwargs={"mailbox_id": mailbox.id}) + data = {"name": "Domain Admin Widget", "type": "widget", "settings": {}} + + response = api_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == "Domain Admin Widget" diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index 3fcd66e8..1ab06e06 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -23,6 +23,7 @@ pytestmark = pytest.mark.django_db AI_MODEL=None, FEATURE_AI_SUMMARY=False, FEATURE_AI_AUTOLABELS=False, + FEATURE_MAILBOX_ADMIN_CHANNELS=[], DRIVE_CONFIG={"base_url": None, "app_name": "Drive"}, MAX_OUTGOING_ATTACHMENT_SIZE=20971520, # 20MB MAX_OUTGOING_BODY_SIZE=5242880, # 5MB @@ -48,6 +49,7 @@ def test_api_config(is_authenticated): "AI_ENABLED": False, "FEATURE_AI_SUMMARY": False, "FEATURE_AI_AUTOLABELS": False, + "FEATURE_MAILBOX_ADMIN_CHANNELS": [], "SCHEMA_CUSTOM_ATTRIBUTES_USER": {}, "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN": {}, "MAX_INCOMING_EMAIL_SIZE": 10485760, diff --git a/src/backend/core/tests/api/test_inbound_widget.py b/src/backend/core/tests/api/test_inbound_widget.py index 494ec365..f3b90f1d 100644 --- a/src/backend/core/tests/api/test_inbound_widget.py +++ b/src/backend/core/tests/api/test_inbound_widget.py @@ -302,11 +302,25 @@ class TestInboundWidgetDeliver: in parsed_email["htmlBody"][0]["content"] ) - def test_inbound_widget_deliver_message_e2e(self, api_client, channel): - """Test that message is properly formatted with HTML and metadata.""" + def test_inbound_widget_deliver_message_e2e(self, api_client): + """Test that message is properly formatted with HTML, metadata, and tags.""" assert models.Message.objects.count() == 0 + # Create a mailbox with labels and a channel with tags + mailbox = factories.MailboxFactory() + label1 = factories.LabelFactory(mailbox=mailbox, name="Widget Support") + label2 = factories.LabelFactory(mailbox=mailbox, name="Urgent") + channel = factories.ChannelFactory( + type="widget", + mailbox=mailbox, + settings={ + "config": {"enabled": True}, + "tags": [str(label1.id), str(label2.id)], + "subject_template": "Contact from {referer_domain}", + }, + ) + data = { "email": "sender@example.com", "textBody": "Line 1\nLine 2\nLine 3", @@ -322,12 +336,16 @@ class TestInboundWidgetDeliver: assert response.status_code == status.HTTP_200_OK assert models.Message.objects.count() == 1 - mailbox = channel.mailbox message = models.Message.objects.first() # Check we have a threadaccess on the right mailbox assert message.thread.accesses.first().mailbox == mailbox assert message.sender.email == "sender@example.com" - assert message.subject == "Message from example.com" + assert message.subject == "Contact from example.com" + + # Check that channel tags were applied to the thread + thread_label_ids = set(message.thread.labels.values_list("id", flat=True)) + assert label1.id in thread_label_ids + assert label2.id in thread_label_ids authenticated_user = factories.UserFactory() factories.MailboxAccessFactory( diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 89207903..8845f16a 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -6,6 +6,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from core.api.viewsets.blob import BlobViewSet +from core.api.viewsets.channel import ChannelViewSet from core.api.viewsets.config import ConfigView from core.api.viewsets.contacts import ContactViewSet from core.api.viewsets.draft import DraftMessageView @@ -118,6 +119,15 @@ mailbox_message_template_nested_router.register( basename="mailbox-message-templates", ) +# Router for /mailboxes/{mailbox_id}/channels/ +# allow to manage integration channels for a mailbox +mailbox_channel_nested_router = DefaultRouter() +mailbox_channel_nested_router.register( + r"channels", + ChannelViewSet, + basename="mailbox-channels", +) + urlpatterns = [ path( f"api/{settings.API_VERSION}/", @@ -148,6 +158,12 @@ urlpatterns = [ mailbox_message_template_nested_router.urls ), # Includes /mailboxes/{id}/message-templates/ ), + path( + "mailboxes//", + include( + mailbox_channel_nested_router.urls + ), # Includes /mailboxes/{id}/channels/ + ), path( "maildomains//", include(maildomain_nested_router.urls), diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index b64267a5..f10bec46 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -771,6 +771,9 @@ class Base(Configuration): FEATURE_IMPORT_MESSAGES = values.BooleanValue( default=True, environ_name="FEATURE_IMPORT_MESSAGES", environ_prefix=None ) + FEATURE_MAILBOX_ADMIN_CHANNELS = values.ListValue( + default=[], environ_name="FEATURE_MAILBOX_ADMIN_CHANNELS", environ_prefix=None + ) # Logging # We want to make it easy to log to console but by default we log production diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index dd988d81..d2522daf 100644 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -47,13 +47,17 @@ "Accesses": "Accesses", "Actions": "Actions", "Active": "Active", + "Add a contact form widget to your website to receive messages directly in your mailbox.": "Add a contact form widget to your website to receive messages directly in your mailbox.", "Add a domain": "Add a domain", "Add a sub-label": "Add a sub-label", "Add attachment from {{driveAppName}}": "Add attachment from {{driveAppName}}", "Add attachments": "Add attachments", "Add label": "Add label", "Add labels": "Add labels", + "Add tags": "Add tags", + "Add this code snippet to your website to display the feedback widget.": "Add this code snippet to your website to display the feedback widget.", "Address": "Address", + "After creating the widget, you will receive the installation code to add to your website.": "After creating the widget, you will receive the installation code to add to your website.", "Addresses": "Addresses", "All messages": "All messages", "An address with this prefix already exists in this domain.": "An address with this prefix already exists in this domain.", @@ -65,7 +69,9 @@ "An error occurred while resetting the password.": "An error occurred while resetting the password.", "An error occurred while updating the address.": "An error occurred while updating the address.", "An error occurred while uploading the archive file.": "An error occurred while uploading the archive file.", + "An error occurred while saving the integration.": "An error occurred while saving the integration.", "An unexpected error occurred.": "An unexpected error occurred.", + "API Key": "API Key", "and {{count}} other users_one": "and 1 other user", "and {{count}} other users_other": "and {{count}} other users", "Archive": "Archive", @@ -77,6 +83,7 @@ "Are you sure you want to delete this label? This action is irreversible!": "Are you sure you want to delete this label? This action is irreversible!", "Are you sure you want to delete this mailbox? This action is irreversible!": "Are you sure you want to delete this mailbox? This action is irreversible!", "Are you sure you want to delete this signature? This action is irreversible!": "Are you sure you want to delete this signature? This action is irreversible!", + "Are you sure you want to delete this integration? This action is irreversible!": "Are you sure you want to delete this integration? This action is irreversible!", "Are you sure you want to delete this template? This action is irreversible!": "Are you sure you want to delete this template? This action is irreversible!", "Are you sure you want to reset the password?": "Are you sure you want to reset the password?", "At least one recipient is required.": "At least one recipient is required.", @@ -107,8 +114,11 @@ "Collapse all": "Collapse all", "Color: ": "Color: ", "Contains the words": "Contains the words", + "Choose the type of integration you want to create": "Choose the type of integration you want to create", + "Coming soon": "Coming soon", "Content is required": "Content is required", "Copied": "Copied", + "Copied to clipboard": "Copied to clipboard", "Copy": "Copy", "Copy all DNS records": "Copy all DNS records", "Copy to clipboard": "Copy to clipboard", @@ -122,8 +132,11 @@ "Create a new shared mailbox": "Create a new shared mailbox", "Create a new signature": "Create a new signature", "Create a new signature for {{domain}}": "Create a new signature for {{domain}}", + "Create a new integration": "Create a new integration", "Create a new template": "Create a new template", + "Create a Widget": "Create a Widget", "Create a simple redirect (Coming soon)": "Create a simple redirect (Coming soon)", + "Create integration": "Create integration", "Create the label \"{{label}}\"": "Create the label \"{{label}}\"", "create_mailbox_modal.success.credential_text": "Your Messages credentials are:\n- Email: {{id}}\n- Password: {{password}}\n\nIt will be asked to change your password at your first login.", "Created at": "Created at", @@ -140,6 +153,7 @@ "Delete label \"{{label}}\"": "Delete label \"{{label}}\"", "Delete mailbox {{mailbox}}": "Delete mailbox {{mailbox}}", "Delete signature \"{{signature}}\"": "Delete signature \"{{signature}}\"", + "Delete integration \"{{name}}\"": "Delete integration \"{{name}}\"", "Delete template \"{{template}}\"": "Delete template \"{{template}}\"", "Description": "Description", "Description must be less than 255 characters.": "Description must be less than 255 characters.", @@ -166,15 +180,18 @@ "Edit {{mailbox}} address": "Edit {{mailbox}} address", "Edit signature \"{{signature}}\"": "Edit signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Edit template \"{{template}}\"", + "Edit Widget": "Edit Widget", "Email address": "Email address", "EML or MBOX": "EML or MBOX", "Enter the email addresses of the recipients separated by commas": "Enter the email addresses of the recipients separated by commas", "Error while checking DNS records": "Error while checking DNS records", "Error while loading addresses": "Error while loading addresses", "Error while loading signatures": "Error while loading signatures", + "Error while loading integrations": "Error while loading integrations", "Error while loading templates": "Error while loading templates", "Expand": "Expand", "Expand all": "Expand all", + "Failed to delete integration.": "Failed to delete integration.", "Failed to delete signature.": "Failed to delete signature.", "Failed to delete template.": "Failed to delete template.", "Failed to refresh summary.": "Failed to refresh summary.", @@ -197,6 +214,8 @@ "From: ": "From: ", "Full name": "Full name", "Full name is required.": "Full name is required.", + "General": "General", + "Generate an API key to send messages programmatically from your applications.": "Generate an API key to send messages programmatically from your applications.", "Generating summary...": "Generating summary...", "How to allow IMAP connections from your account {{name}}?": "How to allow IMAP connections from your account {{name}}?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.", @@ -218,6 +237,11 @@ "Incorrect": "Incorrect", "Indicate your old email address and your password.": "Indicate your old email address and your password.", "Insert template": "Insert template", + "Installation": "Installation", + "Integration created!": "Integration created!", + "Integration deleted!": "Integration deleted!", + "Integration updated!": "Integration updated!", + "Integrations": "Integrations", "just now": "just now", "Label \"{{label}}\" assigned to {{count}} threads._one": "Label \"{{label}}\" assigned to this thread.", "Label \"{{label}}\" assigned to {{count}} threads._other": "Label \"{{label}}\" assigned to {{count}} threads.", @@ -230,8 +254,10 @@ "Last update: {{timestamp}}": "Last update: {{timestamp}}", "less than a minute ago": "less than a minute ago", "Loading addresses...": "Loading addresses...", + "Loading integrations...": "Loading integrations...", "Loading labels...": "Loading labels...", "Loading next threads...": "Loading next threads...", + "Loading tags...": "Loading tags...", "Loading signatures...": "Loading signatures...", "Loading templates...": "Loading templates...", "Loading variables...": "Loading variables...", @@ -268,6 +294,7 @@ "Name must be a valid domain name.": "Name must be a valid domain name.", "New address": "New address", "New domain": "New domain", + "New integration": "New integration", "New message": "New message", "New signature": "New signature", "New template": "New template", @@ -275,12 +302,14 @@ "No addresses found": "No addresses found", "No attachments": "No attachments", "No DNS records found": "No DNS records found", + "No integration found": "No integration found", "No mailbox.": "No mailbox.", "No results.": "No results.", "No signature": "No signature", "No signatures found": "No signatures found", "No subject": "No subject", "No summary available.": "No summary available.", + "No tags selected": "No tags selected", "No template found": "No template found", "No templates available": "No templates available", "No threads.": "No threads.", @@ -303,6 +332,7 @@ "Refresh": "Refresh", "Refresh summary": "Refresh summary", "Remove": "Remove", + "Remove tag": "Remove tag", "Remove report": "Remove report", "Remove spam report": "Remove spam report", "Remove spam report from {{count}} threads_one": "Remove spam report from {{count}} thread", @@ -317,11 +347,14 @@ "Reset password of {{mailbox}}": "Reset password of {{mailbox}}", "Retry": "Retry", "Save": "Save", + "Save changes": "Save changes", "Save into your {{driveAppName}}'s workspace": "Save into your {{driveAppName}}'s workspace", "Saving...": "Saving...", "Search": "Search", "Search a label": "Search a label", + "Search a tag": "Search a tag", "Search in messages...": "Search in messages...", + "Select tags to automatically apply to messages received from this widget.": "Select tags to automatically apply to messages received from this widget.", "See members of this thread ({{count}} members)_one": "See members of this thread ({{count}} members)", "See members of this thread ({{count}} members)_other": "See members of this thread ({{count}} members)", "Select a parent label": "Select a parent label", @@ -335,6 +368,7 @@ "Send Feedback": "Send Feedback", "Sending message...": "Sending message...", "Sent": "Sent", + "Settings": "Settings", "Share access": "Share access", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.", "Share the new credentials to the user.": "Share the new credentials to the user.", @@ -358,10 +392,14 @@ "Subject": "Subject", "Subject:": "Subject:", "Subject: ": "Subject: ", + "Subject template": "Subject template", + "Subject template is required.": "Subject template is required.", + "Message from {referer_domain}": "Message from {referer_domain}", "Summarize": "Summarize", "Summary": "Summary", "Summary refreshed!": "Summary refreshed!", "Synchronize mailboxes with an identity provider": "Synchronize mailboxes with an identity provider", + "Tags": "Tags", "Target": "Target", "Target email": "Target email", "Template created!": "Template created!", @@ -382,6 +420,7 @@ "The shared mailbox {{mailboxAddress}} has been created successfully.": "The shared mailbox {{mailboxAddress}} has been created successfully.", "The upload failed. Please try again.": "The upload failed. Please try again.", "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. If you don't know how to update them, please contact your technical service provider or system administrator.": "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. If you don't know how to update them, please contact your technical service provider or system administrator.", + "These tags will be automatically applied to every incoming message from the widget.": "These tags will be automatically applied to every incoming message from the widget.", "This action cannot be undone and the user will need the new password to access its mailbox.": "This action cannot be undone and the user will need the new password to access its mailbox.", "This contact's identity could not be verified. Proceed with caution.": "This contact's identity could not be verified. Proceed with caution.", "This description will be used by the AI to automatically assign this label to your messages.": "This description will be used by the AI to automatically assign this label to your messages.", @@ -392,6 +431,7 @@ "This message has been deleted.": "This message has been deleted.", "This message has not been delivered.": "This message has not been delivered.", "This message is being delivered.": "This message is being delivered.", + "This name is for internal use only and will not be visible to users.": "This name is for internal use only and will not be visible to users.", "This signature is forced": "This signature is forced", "This thread has been reported as spam.": "This thread has been reported as spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "This thread has been reported as spam. For your security, downloading attachments has been disabled.", @@ -406,6 +446,7 @@ "Trash": "Trash", "Type": "Type", "Unable to copy credentials.": "Unable to copy credentials.", + "Unable to copy to clipboard.": "Unable to copy to clipboard.", "Unarchive": "Unarchive", "Unarchive {{count}} threads_one": "Unarchive {{count}} thread", "Unarchive {{count}} threads_other": "Unarchive {{count}} threads", @@ -424,10 +465,14 @@ "Uploading your archive": "Uploading your archive", "Uploading... {{progress}}%": "Uploading... {{progress}}%", "Use \"Send and archive\" by default": "Use \"Send and archive\" by default", + "Use {referer_domain} to include the website domain in the subject.": "Use {referer_domain} to include the website domain in the subject.", "Use SSL": "Use SSL", "Value": "Value", "Variables": "Variables", + "View full documentation": "View full documentation", + "Website Widget": "Website Widget", "While the signature is disabled, it will not be available to the users.": "While the signature is disabled, it will not be available to the users.", + "Widget": "Widget", "Yesterday": "Yesterday", "You": "You", "You are the last editor of this thread, you cannot therefore modify your access.": "You are the last editor of this thread, you cannot therefore modify your access.", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index ab5aff7c..34ac75b6 100644 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -48,11 +48,14 @@ "Actions": "Actions", "Active": "Active", "Add a domain": "Ajout d'un domaine", + "Add a contact form widget to your website to receive messages directly in your mailbox.": "Ajoutez un widget de formulaire de contact à votre site web pour recevoir des messages directement dans votre boîte aux lettres.", "Add a sub-label": "Ajouter un sous-libellé", "Add attachment from {{driveAppName}}": "Ajouter une pièce jointe depuis {{driveAppName}}", "Add attachments": "Ajoutez des pièces jointes", "Add label": "Ajouter un libellé", "Add labels": "Ajouter des libellés", + "Add tags": "Ajouter des tags", + "Add this code snippet to your website to display the feedback widget.": "Ajoutez ce code à votre site web pour afficher le widget de contact.", "Address": "Adresse", "Addresses": "Adresses", "All messages": "Tous les messages", @@ -65,7 +68,10 @@ "An error occurred while resetting the password.": "Une erreur s'est produite lors de la réinitialisation du mot de passe.", "An error occurred while updating the address.": "Une erreur est survenue lors de la mise à jour de l'adresse.", "An error occurred while uploading the archive file.": "Une erreur est survenue lors du téléversement de l'archive.", - "An unexpected error occurred.": "Une erreur inattendue s’est produite.", + "An error occurred while saving the integration.": "Une erreur est survenue lors de la sauvegarde de l'intégration.", + "An unexpected error occurred.": "Une erreur inattendue s'est produite.", + "API Key": "Clé API", + "After creating the widget, you will receive the installation code to add to your website.": "Après avoir créé le widget, vous recevrez le code d'installation à ajouter à votre site web.", "and {{count}} other users_one": "et 1 autre utilisateur", "and {{count}} other users_other": "et {{count}} autres utilisateurs", "Archive": "Archiver", @@ -77,6 +83,7 @@ "Are you sure you want to delete this label? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce libellé ? Cette action est irréversible !", "Are you sure you want to delete this mailbox? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette boîte aux lettres ? Cette action est irréversible !", "Are you sure you want to delete this signature? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette signature ? Cette action est irréversible !", + "Are you sure you want to delete this integration? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette intégration ? Cette action est irréversible !", "Are you sure you want to delete this template? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce modèle ? Cette action est irréversible !", "Are you sure you want to reset the password?": "Êtes-vous sûr de vouloir réinitialiser le mot de passe ?", "At least one recipient is required.": "Il faut au moins un destinataire.", @@ -108,7 +115,10 @@ "Color: ": "Couleur : ", "Contains the words": "Contient les mots", "Content is required": "Un contenu est requis", + "Choose the type of integration you want to create": "Choisissez le type d'intégration que vous souhaitez créer", + "Coming soon": "Bientôt disponible", "Copied": "Copié", + "Copied to clipboard": "Copié dans le presse-papiers", "Copy": "Copier", "Copy all DNS records": "Copier tous les enregistrements DNS", "Copy to clipboard": "Copier dans le presse-papiers", @@ -117,8 +127,11 @@ "Create": "Créer", "Create a Label": "Créer un libellé", "Create a new address @{{domain}}": "Création d'une nouvelle adresse @{{domain}}", + "Create a new integration": "Créer une nouvelle intégration", "Create a new label": "Créer un nouveau libellé", "Create a new personal mailbox": "Créer une nouvelle boîte personnelle", + "Create a Widget": "Créer un Widget", + "Create integration": "Créer l'intégration", "Create a new shared mailbox": "Créer une nouvelle boîte partagée", "Create a new signature": "Créer une nouvelle signature", "Create a new signature for {{domain}}": "Création d'une nouvelle signature pour {{domain}}", @@ -140,6 +153,7 @@ "Delete label \"{{label}}\"": "Supprimer le libellé \"{{label}}\"", "Delete mailbox {{mailbox}}": "Supprimer la boîte aux lettres {{mailbox}}", "Delete signature \"{{signature}}\"": "Supprimer la signature \"{{signature}}\"", + "Delete integration \"{{name}}\"": "Supprimer l'intégration \"{{name}}\"", "Delete template \"{{template}}\"": "Supprimer le modèle \"{{template}}\"", "Description": "Description", "Description must be less than 255 characters.": "La description ne peut pas excéder 255 caractères.", @@ -166,15 +180,18 @@ "Edit {{mailbox}} address": "Modifier l'adresse {{mailbox}}", "Edit signature \"{{signature}}\"": "Modifier la signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Modifier le modèle \"{{template}}\"", + "Edit Widget": "Modifier le Widget", "Email address": "Adresse mail", "EML or MBOX": "EML ou MBOX", "Enter the email addresses of the recipients separated by commas": "Entrez les adresses e-mail des destinataires séparés par des virgules", "Error while checking DNS records": "Erreur lors de la vérification des enregistrements DNS", "Error while loading addresses": "Erreur lors du chargement des adresses", "Error while loading signatures": "Erreur lors du chargement des signatures", + "Error while loading integrations": "Erreur lors du chargement des intégrations", "Error while loading templates": "Erreur lors du chargement des modèles", "Expand": "Développer", "Expand all": "Tout développer", + "Failed to delete integration.": "Erreur lors de la suppression de l'intégration.", "Failed to delete signature.": "Erreur lors de la suppression de la signature.", "Failed to delete template.": "Erreur lors de la suppression du modèle.", "Failed to refresh summary.": "Erreur lors de la mise à jour du résumé.", @@ -197,6 +214,8 @@ "From: ": "De : ", "Full name": "Nom complet", "Full name is required.": "Le nom complet est requis.", + "General": "Général", + "Generate an API key to send messages programmatically from your applications.": "Générez une clé API pour envoyer des messages de façon programmatique depuis vos applications.", "Generating summary...": "Génération du résumé en cours...", "How to allow IMAP connections from your account {{name}}?": "Comment autoriser les connexions IMAP depuis votre compte {{name}} ?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "Je confirme que cette adresse correspond à l'identité d'une personne physique travaillant avec moi, et m'engage à la désactiver quand son poste prendra fin.", @@ -218,6 +237,11 @@ "Incorrect": "Incorrect", "Indicate your old email address and your password.": "Indiquez votre ancienne adresse mail et votre mot de passe.", "Insert template": "Insérer un modèle", + "Installation": "Installation", + "Integration created!": "Intégration créée !", + "Integration deleted!": "Intégration supprimée !", + "Integration updated!": "Intégration mise à jour !", + "Integrations": "Intégrations", "just now": "à l'instant", "Label \"{{label}}\" assigned to {{count}} threads._one": "Libellé \"{{label}}\" assigné à la conversation.", "Label \"{{label}}\" assigned to {{count}} threads._other": "Libellé \"{{label}}\" assigné à {{count}} conversations.", @@ -230,8 +254,10 @@ "Last update: {{timestamp}}": "Dernière mise à jour : {{timestamp}}", "less than a minute ago": "il y a moins d'une minute", "Loading addresses...": "Chargement des adresses...", + "Loading integrations...": "Chargement des intégrations...", "Loading labels...": "Chargement des libellés...", "Loading next threads...": "Chargement des conversations suivantes...", + "Loading tags...": "Chargement des tags...", "Loading signatures...": "Chargement des signatures...", "Loading templates...": "Chargement des modèles...", "Loading variables...": "Chargement des variables...", @@ -268,6 +294,7 @@ "Name must be a valid domain name.": "Le nom doit être un nom de domaine valide.", "New address": "Nouvelle adresse", "New domain": "Nouveau domaine", + "New integration": "Nouvelle intégration", "New message": "Nouveau message", "New signature": "Nouvelle signature", "New template": "Nouveau modèle", @@ -275,8 +302,10 @@ "No addresses found": "Aucune adresse trouvée", "No attachments": "Aucune pièce jointe", "No DNS records found": "Aucun enregistrement DNS trouvé", + "No integration found": "Aucune intégration trouvée", "No mailbox.": "Aucune boîte aux lettres.", "No results.": "Aucun résultat.", + "No tags selected": "Aucun tag sélectionné", "No signature": "Aucune signature", "No signatures found": "Aucune signature trouvée", "No subject": "Aucun objet", @@ -303,6 +332,7 @@ "Refresh": "Actualiser", "Refresh summary": "Actualiser le résumé", "Remove": "Supprimer", + "Remove tag": "Supprimer le tag", "Remove report": "Annuler le signalement", "Remove spam report": "Annuler le signalement spam", "Remove spam report from {{count}} threads_one": "Annuler le signalement spam de la conversation", @@ -317,11 +347,14 @@ "Reset password of {{mailbox}}": "Réinitialiser le mot de passe de {{mailbox}}", "Retry": "Réessayer", "Save": "Enregistrer", + "Save changes": "Enregistrer les modifications", "Save into your {{driveAppName}}'s workspace": "Sauvegarder dans votre espace de travail {{driveAppName}}", "Saving...": "Enregistrement en cours...", "Search": "Rechercher", "Search a label": "Rechercher un libellé", + "Search a tag": "Rechercher un tag", "Search in messages...": "Rechercher dans vos messages...", + "Select tags to automatically apply to messages received from this widget.": "Sélectionnez les tags à appliquer automatiquement aux messages reçus depuis ce widget.", "See members of this thread ({{count}} members)_one": "Voir les membres de cette conversation ({{count}} membre)", "See members of this thread ({{count}} members)_other": "Voir les membres de cette conversation ({{count}} membres)", "Select a parent label": "Sélectionnez un libellé parent", @@ -335,6 +368,7 @@ "Send Feedback": "Envoyer le message", "Sending message...": "Envoi du message en cours...", "Sent": "Envoyés", + "Settings": "Paramètres", "Share access": "Partager l'accès", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Transmettez les identifiants de cette boîte mail à son utilisateur de façon sécurisée, de préférence physiquement.", "Share the new credentials to the user.": "Transmettez les nouveaux identifiants à l'utilisateur.", @@ -358,10 +392,14 @@ "Subject": "Objet", "Subject:": "Objet :", "Subject: ": "Objet : ", + "Subject template": "Modèle d'objet", + "Subject template is required.": "Le modèle d'objet est requis.", + "Message from {referer_domain}": "Message de {referer_domain}", "Summarize": "Résumer", "Summary": "Résumé", "Summary refreshed!": "Résumé mis à jour !", "Synchronize mailboxes with an identity provider": "Synchroniser les boîtes aux lettres avec un fournisseur d'identité", + "Tags": "Tags", "Target": "Cible", "Target email": "Adresse de destination", "Template created!": "Modèle créé !", @@ -382,6 +420,7 @@ "The shared mailbox {{mailboxAddress}} has been created successfully.": "L'adresse partagée {{mailboxAddress}} a été créée avec succès.", "The upload failed. Please try again.": "Le téléversement a échoué. Veuillez réessayer.", "These DNS records must be configured on the domain {{domain}} for the mail system to work properly. If you don't know how to update them, please contact your technical service provider or system administrator.": "Ces enregistrements DNS doivent être configurés sur le domaine {{domain}} pour que le système de messagerie fonctionne correctement. Si vous ne savez pas comment les mettre à jour, contactez votre prestataire technique ou votre administrateur système.", + "These tags will be automatically applied to every incoming message from the widget.": "Ces tags seront automatiquement appliqués à chaque message entrant provenant du widget.", "This action cannot be undone and the user will need the new password to access its mailbox.": "Cette action est irréversible et l'utilisateur aura besoin du nouveau mot de passe pour accéder à sa boîte aux lettres.", "This contact's identity could not be verified. Proceed with caution.": "L'identité de ce contact n'a pas pu être vérifiée. Faites attention.", "This description will be used by the AI to automatically assign this label to your messages.": "Cette description sera utilisée par l'IA pour assigner automatiquement cette étiquette à vos messages.", @@ -392,6 +431,7 @@ "This message has been deleted.": "Ce message a été supprimé.", "This message has not been delivered.": "Ce message n'a pas pu être délivré.", "This message is being delivered.": "Ce message est en cours d'envoi.", + "This name is for internal use only and will not be visible to users.": "Ce nom est réservé à un usage interne et ne sera pas visible par les utilisateurs.", "This signature is forced": "Cette signature est forcée", "This thread has been reported as spam.": "Cette conversation a été signalée comme spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "Cette conversation a été signalée comme spam. Pour votre sécurité, le téléchargement des pièces jointes a été désactivé.", @@ -406,6 +446,7 @@ "Trash": "Corbeille", "Type": "Type", "Unable to copy credentials.": "Impossible de copier les identifiants.", + "Unable to copy to clipboard.": "Impossible de copier dans le presse-papiers.", "Unarchive": "Désarchiver", "Unarchive {{count}} threads_one": "Désarchiver la conversation", "Unarchive {{count}} threads_other": "Désarchiver {{count}} conversations", @@ -424,10 +465,14 @@ "Uploading your archive": "Téléversement de votre archive", "Uploading... {{progress}}%": "Téléversement en cours... {{progress}}%", "Use \"Send and archive\" by default": "Utiliser \"Envoyer et archiver\" par défaut", + "Use {referer_domain} to include the website domain in the subject.": "Utilisez {referer_domain} pour inclure le domaine du site web dans l'objet.", "Use SSL": "Utiliser SSL", "Value": "Valeur", "Variables": "Variables", + "View full documentation": "Voir la documentation complète", + "Website Widget": "Widget de site web", "While the signature is disabled, it will not be available to the users.": "Tant que la signature est désactivée, elle ne sera pas disponible pour les utilisateurs.", + "Widget": "Widget", "Yesterday": "Hier", "You": "Vous", "You are the last editor of this thread, you cannot therefore modify your access.": "Vous êtes le dernier éditeur de cette conversation, vous ne pouvez donc pas modifier votre accès.", diff --git a/src/frontend/src/features/api/gen/channels/channels.ts b/src/frontend/src/features/api/gen/channels/channels.ts new file mode 100644 index 00000000..740fc452 --- /dev/null +++ b/src/frontend/src/features/api/gen/channels/channels.ts @@ -0,0 +1,891 @@ +/** + * Generated by orval v7.17.2 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + Channel, + ChannelRequest, + PatchedChannelRequest, +} from ".././models"; + +import { fetchAPI } from "../../fetch-api"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * Manage integration channels for a mailbox + */ +export type mailboxesChannelsListResponse200 = { + data: Channel[]; + status: 200; +}; + +export type mailboxesChannelsListResponseSuccess = + mailboxesChannelsListResponse200 & { + headers: Headers; + }; +export type mailboxesChannelsListResponse = + mailboxesChannelsListResponseSuccess; + +export const getMailboxesChannelsListUrl = (mailboxId: string) => { + return `/api/v1.0/mailboxes/${mailboxId}/channels/`; +}; + +export const mailboxesChannelsList = async ( + mailboxId: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesChannelsListUrl(mailboxId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMailboxesChannelsListQueryKey = (mailboxId?: string) => { + return [`/api/v1.0/mailboxes/${mailboxId}/channels/`] as const; +}; + +export const getMailboxesChannelsListQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getMailboxesChannelsListQueryKey(mailboxId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + mailboxesChannelsList(mailboxId, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!mailboxId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MailboxesChannelsListQueryResult = NonNullable< + Awaited> +>; +export type MailboxesChannelsListQueryError = unknown; + +export function useMailboxesChannelsList< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesChannelsList< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesChannelsList< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMailboxesChannelsList< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMailboxesChannelsListQueryOptions(mailboxId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * Manage integration channels for a mailbox + */ +export type mailboxesChannelsCreateResponse201 = { + data: Channel; + status: 201; +}; + +export type mailboxesChannelsCreateResponse400 = { + data: void; + status: 400; +}; + +export type mailboxesChannelsCreateResponse403 = { + data: void; + status: 403; +}; + +export type mailboxesChannelsCreateResponseSuccess = + mailboxesChannelsCreateResponse201 & { + headers: Headers; + }; +export type mailboxesChannelsCreateResponseError = ( + | mailboxesChannelsCreateResponse400 + | mailboxesChannelsCreateResponse403 +) & { + headers: Headers; +}; + +export type mailboxesChannelsCreateResponse = + | mailboxesChannelsCreateResponseSuccess + | mailboxesChannelsCreateResponseError; + +export const getMailboxesChannelsCreateUrl = (mailboxId: string) => { + return `/api/v1.0/mailboxes/${mailboxId}/channels/`; +}; + +export const mailboxesChannelsCreate = async ( + mailboxId: string, + channelRequest: ChannelRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesChannelsCreateUrl(mailboxId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(channelRequest), + }, + ); +}; + +export const getMailboxesChannelsCreateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: ChannelRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: ChannelRequest }, + TContext +> => { + const mutationKey = ["mailboxesChannelsCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; data: ChannelRequest } + > = (props) => { + const { mailboxId, data } = props ?? {}; + + return mailboxesChannelsCreate(mailboxId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesChannelsCreateMutationResult = NonNullable< + Awaited> +>; +export type MailboxesChannelsCreateMutationBody = ChannelRequest; +export type MailboxesChannelsCreateMutationError = void; + +export const useMailboxesChannelsCreate = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; data: ChannelRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; data: ChannelRequest }, + TContext +> => { + const mutationOptions = getMailboxesChannelsCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage integration channels for a mailbox + */ +export type mailboxesChannelsRetrieveResponse200 = { + data: Channel; + status: 200; +}; + +export type mailboxesChannelsRetrieveResponseSuccess = + mailboxesChannelsRetrieveResponse200 & { + headers: Headers; + }; +export type mailboxesChannelsRetrieveResponse = + mailboxesChannelsRetrieveResponseSuccess; + +export const getMailboxesChannelsRetrieveUrl = ( + mailboxId: string, + id: string, +) => { + return `/api/v1.0/mailboxes/${mailboxId}/channels/${id}/`; +}; + +export const mailboxesChannelsRetrieve = async ( + mailboxId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesChannelsRetrieveUrl(mailboxId, id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMailboxesChannelsRetrieveQueryKey = ( + mailboxId?: string, + id?: string, +) => { + return [`/api/v1.0/mailboxes/${mailboxId}/channels/${id}/`] as const; +}; + +export const getMailboxesChannelsRetrieveQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getMailboxesChannelsRetrieveQueryKey(mailboxId, id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + mailboxesChannelsRetrieve(mailboxId, id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!(mailboxId && id), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MailboxesChannelsRetrieveQueryResult = NonNullable< + Awaited> +>; +export type MailboxesChannelsRetrieveQueryError = unknown; + +export function useMailboxesChannelsRetrieve< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesChannelsRetrieve< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesChannelsRetrieve< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMailboxesChannelsRetrieve< + TData = Awaited>, + TError = unknown, +>( + mailboxId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMailboxesChannelsRetrieveQueryOptions( + mailboxId, + id, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * Manage integration channels for a mailbox + */ +export type mailboxesChannelsUpdateResponse200 = { + data: Channel; + status: 200; +}; + +export type mailboxesChannelsUpdateResponse400 = { + data: void; + status: 400; +}; + +export type mailboxesChannelsUpdateResponse403 = { + data: void; + status: 403; +}; + +export type mailboxesChannelsUpdateResponse404 = { + data: void; + status: 404; +}; + +export type mailboxesChannelsUpdateResponseSuccess = + mailboxesChannelsUpdateResponse200 & { + headers: Headers; + }; +export type mailboxesChannelsUpdateResponseError = ( + | mailboxesChannelsUpdateResponse400 + | mailboxesChannelsUpdateResponse403 + | mailboxesChannelsUpdateResponse404 +) & { + headers: Headers; +}; + +export type mailboxesChannelsUpdateResponse = + | mailboxesChannelsUpdateResponseSuccess + | mailboxesChannelsUpdateResponseError; + +export const getMailboxesChannelsUpdateUrl = ( + mailboxId: string, + id: string, +) => { + return `/api/v1.0/mailboxes/${mailboxId}/channels/${id}/`; +}; + +export const mailboxesChannelsUpdate = async ( + mailboxId: string, + id: string, + channelRequest: ChannelRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesChannelsUpdateUrl(mailboxId, id), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(channelRequest), + }, + ); +}; + +export const getMailboxesChannelsUpdateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string; data: ChannelRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string; data: ChannelRequest }, + TContext +> => { + const mutationKey = ["mailboxesChannelsUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; id: string; data: ChannelRequest } + > = (props) => { + const { mailboxId, id, data } = props ?? {}; + + return mailboxesChannelsUpdate(mailboxId, id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesChannelsUpdateMutationResult = NonNullable< + Awaited> +>; +export type MailboxesChannelsUpdateMutationBody = ChannelRequest; +export type MailboxesChannelsUpdateMutationError = void; + +export const useMailboxesChannelsUpdate = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string; data: ChannelRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; id: string; data: ChannelRequest }, + TContext +> => { + const mutationOptions = getMailboxesChannelsUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage integration channels for a mailbox + */ +export type mailboxesChannelsPartialUpdateResponse200 = { + data: Channel; + status: 200; +}; + +export type mailboxesChannelsPartialUpdateResponseSuccess = + mailboxesChannelsPartialUpdateResponse200 & { + headers: Headers; + }; +export type mailboxesChannelsPartialUpdateResponse = + mailboxesChannelsPartialUpdateResponseSuccess; + +export const getMailboxesChannelsPartialUpdateUrl = ( + mailboxId: string, + id: string, +) => { + return `/api/v1.0/mailboxes/${mailboxId}/channels/${id}/`; +}; + +export const mailboxesChannelsPartialUpdate = async ( + mailboxId: string, + id: string, + patchedChannelRequest: PatchedChannelRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesChannelsPartialUpdateUrl(mailboxId, id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(patchedChannelRequest), + }, + ); +}; + +export const getMailboxesChannelsPartialUpdateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string; data: PatchedChannelRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string; data: PatchedChannelRequest }, + TContext +> => { + const mutationKey = ["mailboxesChannelsPartialUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; id: string; data: PatchedChannelRequest } + > = (props) => { + const { mailboxId, id, data } = props ?? {}; + + return mailboxesChannelsPartialUpdate(mailboxId, id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesChannelsPartialUpdateMutationResult = NonNullable< + Awaited> +>; +export type MailboxesChannelsPartialUpdateMutationBody = PatchedChannelRequest; +export type MailboxesChannelsPartialUpdateMutationError = unknown; + +export const useMailboxesChannelsPartialUpdate = < + TError = unknown, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string; data: PatchedChannelRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; id: string; data: PatchedChannelRequest }, + TContext +> => { + const mutationOptions = + getMailboxesChannelsPartialUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Manage integration channels for a mailbox + */ +export type mailboxesChannelsDestroyResponse204 = { + data: void; + status: 204; +}; + +export type mailboxesChannelsDestroyResponse403 = { + data: void; + status: 403; +}; + +export type mailboxesChannelsDestroyResponse404 = { + data: void; + status: 404; +}; + +export type mailboxesChannelsDestroyResponseSuccess = + mailboxesChannelsDestroyResponse204 & { + headers: Headers; + }; +export type mailboxesChannelsDestroyResponseError = ( + | mailboxesChannelsDestroyResponse403 + | mailboxesChannelsDestroyResponse404 +) & { + headers: Headers; +}; + +export type mailboxesChannelsDestroyResponse = + | mailboxesChannelsDestroyResponseSuccess + | mailboxesChannelsDestroyResponseError; + +export const getMailboxesChannelsDestroyUrl = ( + mailboxId: string, + id: string, +) => { + return `/api/v1.0/mailboxes/${mailboxId}/channels/${id}/`; +}; + +export const mailboxesChannelsDestroy = async ( + mailboxId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesChannelsDestroyUrl(mailboxId, id), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getMailboxesChannelsDestroyMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext +> => { + const mutationKey = ["mailboxesChannelsDestroy"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { mailboxId: string; id: string } + > = (props) => { + const { mailboxId, id } = props ?? {}; + + return mailboxesChannelsDestroy(mailboxId, id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesChannelsDestroyMutationResult = NonNullable< + Awaited> +>; + +export type MailboxesChannelsDestroyMutationError = void; + +export const useMailboxesChannelsDestroy = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { mailboxId: string; id: string }, + TContext +> => { + const mutationOptions = getMailboxesChannelsDestroyMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/src/frontend/src/features/api/gen/index.ts b/src/frontend/src/features/api/gen/index.ts index b5028e2b..78e7f9be 100644 --- a/src/frontend/src/features/api/gen/index.ts +++ b/src/frontend/src/features/api/gen/index.ts @@ -7,6 +7,7 @@ export * from "./import/import"; export * from "./labels/labels"; export * from "./mailboxes/mailboxes"; export * from "./mailbox-accesses/mailbox-accesses"; +export * from "./channels/channels"; export * from "./maildomains/maildomains"; export * from "./maildomain-accesses/maildomain-accesses"; export * from "./placeholders/placeholders"; diff --git a/src/frontend/src/features/api/gen/models/channel.ts b/src/frontend/src/features/api/gen/models/channel.ts new file mode 100644 index 00000000..7eb38a91 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/channel.ts @@ -0,0 +1,41 @@ +/** + * Generated by orval v7.17.2 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize Channel model. + */ +export interface Channel { + /** primary key for the record as UUID */ + readonly id: string; + /** + * Human-readable name for this channel + * @maxLength 255 + */ + name: string; + /** + * Type of channel + * @maxLength 255 + */ + type?: string; + /** Channel-specific configuration settings */ + settings?: unknown; + /** + * primary key for the record as UUID + * @nullable + */ + readonly mailbox: string | null; + /** + * primary key for the record as UUID + * @nullable + */ + readonly maildomain: string | null; + /** date and time at which a record was created */ + readonly created_at: string; + /** date and time at which a record was last updated */ + readonly updated_at: string; +} diff --git a/src/frontend/src/features/api/gen/models/channel_request.ts b/src/frontend/src/features/api/gen/models/channel_request.ts new file mode 100644 index 00000000..e7ff8f85 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/channel_request.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.17.2 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize Channel model. + */ +export interface ChannelRequest { + /** + * Human-readable name for this channel + * @minLength 1 + * @maxLength 255 + */ + name: string; + /** + * Type of channel + * @minLength 1 + * @maxLength 255 + */ + type?: string; + /** Channel-specific configuration settings */ + settings?: unknown; +} 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 c07000e8..42c406a7 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -16,6 +16,7 @@ export type ConfigRetrieve200 = { readonly AI_ENABLED: boolean; readonly FEATURE_AI_SUMMARY: boolean; readonly FEATURE_AI_AUTOLABELS: boolean; + readonly FEATURE_MAILBOX_ADMIN_CHANNELS: readonly string[]; /** The URLs of the Drive external service. */ readonly DRIVE?: ConfigRetrieve200DRIVE; readonly SCHEMA_CUSTOM_ATTRIBUTES_USER: ConfigRetrieve200SCHEMACUSTOMATTRIBUTESUSER; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 11d744ad..f217a4db 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -10,6 +10,8 @@ export * from "./attachment"; export * from "./blob_upload_create201"; export * from "./blob_upload_create_body"; export * from "./change_flag_request_request"; +export * from "./channel"; +export * from "./channel_request"; export * from "./config_retrieve200"; export * from "./config_retrieve200_driv_e"; export * from "./config_retrieve200_schemacustomattributesmaildomai_n"; @@ -98,6 +100,7 @@ export * from "./paginated_mailbox_admin_list"; export * from "./paginated_thread_access_list"; export * from "./paginated_thread_list"; export * from "./partial_drive_item"; +export * from "./patched_channel_request"; export * from "./patched_label_request"; export * from "./patched_mailbox_access_write_request"; export * from "./patched_mailbox_admin_partial_update_payload_request"; diff --git a/src/frontend/src/features/api/gen/models/patched_channel_request.ts b/src/frontend/src/features/api/gen/models/patched_channel_request.ts new file mode 100644 index 00000000..627742f8 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/patched_channel_request.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.17.2 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize Channel model. + */ +export interface PatchedChannelRequest { + /** + * Human-readable name for this channel + * @minLength 1 + * @maxLength 255 + */ + name?: string; + /** + * Type of channel + * @minLength 1 + * @maxLength 255 + */ + type?: string; + /** Channel-specific configuration settings */ + settings?: unknown; +} diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx new file mode 100644 index 00000000..5e8aae31 --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/create-integration-action.tsx @@ -0,0 +1,25 @@ +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { Icon } from "@gouvfr-lasuite/ui-kit"; +import { useTranslation } from "react-i18next"; +import { useModal } from "@gouvfr-lasuite/cunningham-react"; +import { ModalComposeIntegration } from "../modal-compose-integration"; + +export const CreateIntegrationAction = () => { + const { t } = useTranslation(); + const modal = useModal(); + + return ( + <> + + modal.close()} + /> + + ); +}; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx new file mode 100644 index 00000000..be89774b --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/integrations-data-grid.tsx @@ -0,0 +1,179 @@ +import { Icon, IconSize, IconType, Spinner } from "@gouvfr-lasuite/ui-kit"; +import { Button, Column, DataGrid, useModal, useModals } from "@gouvfr-lasuite/cunningham-react"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Mailbox, + Channel, + useMailboxesChannelsList, + useMailboxesChannelsDestroy, + getMailboxesChannelsListUrl +} from "@/features/api/gen"; +import { Banner } from "@/features/ui/components/banner"; +import { addToast, ToasterItem } from "@/features/ui/components/toaster"; +import { ModalComposeIntegration } from "../modal-compose-integration"; +import { handle } from "@/features/utils/errors"; + +type IntegrationsDataGridProps = { + mailbox: Mailbox; +} + +const getChannelTypeLabel = (type: string | undefined, t: (key: string) => string) => { + switch (type) { + case "widget": + return t("Widget"); + case "api_key": + return t("API Key"); + default: + return type; + } +}; + +const getChannelTypeIcon = (type: string | undefined) => { + switch (type) { + case "widget": + return "widgets"; + case "api_key": + return "key"; + default: + return "integration_instructions"; + } +}; + +export const IntegrationsDataGrid = ({ mailbox }: IntegrationsDataGridProps) => { + const { t } = useTranslation(); + const modals = useModals(); + const modal = useModal(); + const { data: channels, isLoading, error } = useMailboxesChannelsList( + mailbox.id, + { + query: { + enabled: !!mailbox.id, + }, + } + ); + const { mutateAsync: deleteChannel, isPending: isDeleting } = useMailboxesChannelsDestroy(); + const [selectedChannel, setSelectedChannel] = useState(); + const queryClient = useQueryClient(); + + const invalidateChannels = async () => { + await queryClient.invalidateQueries({ queryKey: [getMailboxesChannelsListUrl(mailbox.id)], exact: false }); + } + + const handleModifyRow = (channel: Channel) => { + setSelectedChannel(channel); + modal.open(); + } + + const handleDeleteRow = async (channel: Channel) => { + const decision = await modals.deleteConfirmationModal({ + title: {t('Delete integration "{{name}}"', { name: channel.name })}, + children: t('Are you sure you want to delete this integration? This action is irreversible!'), + }); + if (decision === 'delete') { + try { + await deleteChannel({ mailboxId: mailbox.id, id: channel.id }); + await invalidateChannels(); + addToast( + + {t("Integration deleted!")} + , + ); + } catch (error) { + handle(error); + addToast( + + {t("Failed to delete integration.")} + , + ); + } + } + } + + const columns: Column[] = [ + { + id: "name", + headerName: t("Name"), + renderCell: ({ row }) => ( +
+ + {row.name} +
+ ), + }, + { + id: "type", + headerName: t("Type"), + size: 150, + renderCell: ({ row }) => getChannelTypeLabel(row.type, t), + }, + { + id: "actions", + size: 154, + headerName: t("Actions"), + renderCell: ({ row }) => ( +
+ +
+ ), + }, + ]; + + if (isLoading) { + return ( +
+ }> + {t("Loading integrations...")} + +
+ ); + } + + if (error) { + return ( +
+ + {t("Error while loading integrations")} + +
+ ); + } + + return ( +
+
+ undefined} + enableSorting={false} + emptyPlaceholderLabel={t("No integration found")} + /> + { + modal.close(); + setSelectedChannel(undefined); + }} + channel={selectedChannel} + onSuccess={invalidateChannels} + /> +
+
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/page-content.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/page-content.tsx new file mode 100644 index 00000000..4ecb05cd --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/integrations-view/page-content.tsx @@ -0,0 +1,16 @@ +import { useMailboxContext } from "@/features/providers/mailbox"; +import { IntegrationsDataGrid } from "./integrations-data-grid"; + +export const IntegrationsPageContent = () => { + const { selectedMailbox } = useMailboxContext(); + + if (!selectedMailbox) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss new file mode 100644 index 00000000..c002110e --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/_index.scss @@ -0,0 +1,263 @@ +@use "sass:map"; +@use "../../../../../styles/cunningham-tokens" as *; + +.modal-compose-integration { + min-height: 300px; +} + +// Channel Type Selector +.channel-type-selector { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--xl); +} + +.channel-type-selector__subtitle { + color: var(--c--contextuals--content--semantic--neutral--secondary); + margin: 0; +} + +.channel-type-selector__cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--c--globals--spacings--base); +} + +.channel-type-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--c--globals--spacings--base); + padding: var(--c--globals--spacings--xl); + background: var(--c--contextuals--background--surface--primary); + border: 1px solid var(--c--contextuals--border--semantic--neutral--default); + border-radius: var(--c--globals--spacings--sm); + cursor: pointer; + transition: all 0.2s ease; + text-align: center; + position: relative; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + + &:hover:not(.channel-type-card--disabled) { + border-color: var(--c--contextuals--border--semantic--brand--default); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + } + + &:focus-visible:not(.channel-type-card--disabled) { + outline: 2px solid var(--c--contextuals--border--semantic--brand--default); + outline-offset: 2px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + } + + &:active:not(.channel-type-card--disabled) { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + } + + &--disabled { + opacity: 0.6; + cursor: not-allowed; + background: var(--c--contextuals--background--surface--secondary); + } +} + +.channel-type-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + background: var(--c--contextuals--background--surface--brand--soft); + border-radius: 50%; + flex-shrink: 0; + color: var(--c--contextuals--content--semantic--brand--primary); +} + +.channel-type-card__content { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--xs); +} + +.channel-type-card__title { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + color: var(--c--contextuals--content--semantic--neutral--primary); +} + +.channel-type-card__description { + font-size: 0.875rem; + color: var(--c--contextuals--content--semantic--neutral--secondary); + margin: 0; + line-height: 1.5; +} + +.channel-type-card__badge { + position: absolute; + top: var(--c--globals--spacings--sm); + right: var(--c--globals--spacings--sm); + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 10px; + background: var(--c--contextuals--background--surface--tertiary); + border-radius: 100px; + color: var(--c--contextuals--content--semantic--neutral--tertiary); +} + +// Widget Integration Form +.widget-integration-form { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--lg); +} + +.widget-integration-form__header { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--base); +} + +.widget-integration-form__section { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--sm); + + h3 { + font-size: 0.95rem; + font-weight: 600; + margin: 0; + color: var(--c--contextuals--content--semantic--neutral--primary); + } + + &--info { + flex-direction: row; + align-items: flex-start; + padding: var(--c--globals--spacings--base); + background: var(--c--contextuals--background--surface--brand--soft); + border-radius: var(--c--globals--spacings--xs); + + .material-icons { + color: var(--c--contextuals--content--semantic--brand--primary); + flex-shrink: 0; + } + + p { + margin: 0; + font-size: 0.9rem; + color: var(--c--contextuals--content--semantic--neutral--secondary); + } + } +} + +.widget-integration-form__section-description { + font-size: 0.85rem; + color: var(--c--contextuals--content--semantic--neutral--secondary); + margin: 0; +} + +.widget-integration-form__snippet { + position: relative; + background: var(--c--contextuals--background--surface--tertiary); + border: 1px solid var(--c--contextuals--border--semantic--neutral--default); + border-radius: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--base); + + pre { + margin: 0; + overflow-x: auto; + font-size: 0.8rem; + line-height: 1.5; + } + + code { + font-family: "Fira Code", "Consolas", monospace; + } + + button { + position: absolute; + top: var(--c--globals--spacings--xs); + right: var(--c--globals--spacings--xs); + } +} + +.widget-integration-form__doc-link { + margin: 0; + + a { + display: inline-flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + color: var(--c--contextuals--content--semantic--brand--primary); + text-decoration: none; + font-size: 0.85rem; + + &:hover { + text-decoration: underline; + } + } +} + +.widget-integration-form__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--c--globals--spacings--sm); + padding-top: var(--c--globals--spacings--base); + border-top: 1px solid var(--c--contextuals--border--semantic--neutral--default); +} + +// Tags Selector (reuses thread-labels-widget and cunningham styles) +.tags-selector { + position: relative; + width: 100% !important; + + &--loading { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + } +} + +.tags-selector__wrapper { + cursor: pointer; + width: 100%; + border-radius: var(--c--components--forms-select--border-radius); + border: var(--c--components--forms-select--border-width) var(--c--components--forms-select--border-style) var(--c--components--forms-select--border-color); + background-color: var(--c--components--forms-select--background-color); + min-height: var(--c--components--forms-select--height); + padding: 0 var(--c--globals--spacings--s); + display: flex; + box-sizing: border-box; + transition: border var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out), + box-shadow var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out), + border-radius var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out); + + &:hover { + border-radius: var(--c--components--forms-select--border-radius--hover); + border-color: var(--c--components--forms-select--border-color--hover); + box-shadow: var(--c--components--forms-select--border-color--hover) 0 0 0 1px; + } + + .c__labelled-box { + min-height: inherit; + width: 100%; + } +} + +.tags-selector__value { + display: flex; + flex-wrap: wrap; + gap: var(--c--globals--spacings--xs); + flex: 1; + min-height: 24px; +} + +.tags-selector__popup { + left: 0; + right: auto; +} diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx new file mode 100644 index 00000000..ff4256e1 --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/index.tsx @@ -0,0 +1,193 @@ +import { Modal, ModalSize, Button } from "@gouvfr-lasuite/cunningham-react"; +import { Icon, IconType, IconSize } from "@gouvfr-lasuite/ui-kit"; +import { useTranslation } from "react-i18next"; +import { useState, useEffect } from "react"; +import { Channel } from "@/features/api/gen"; +import { WidgetIntegrationForm } from "./widget-integration-form"; +import { useConfig } from "@/features/providers/config"; + +type ModalComposeIntegrationProps = { + isOpen: boolean; + onClose: () => void; + channel?: Channel; + onSuccess?: () => void; +}; + +type ChannelType = "widget" | "api_key"; +type ViewState = "select_type" | "form"; + +type ChannelTypeCardProps = { + title: string; + description: string; + icon: string; + disabled?: boolean; + onClick: () => void; +}; + +type ChannelTypeMetadata = { + type: ChannelType; + title: string; + description: string; + icon: string; + disabled?: boolean; +}; + +const CHANNEL_TYPE_METADATA: Record = { + widget: { + type: "widget", + title: "Website Widget", + description: "Add a contact form widget to your website to receive messages directly in your mailbox.", + icon: "widgets", + }, + api_key: { + type: "api_key", + title: "API Key", + description: "Generate an API key to send messages programmatically from your applications.", + icon: "key", + disabled: true + }, +}; + +const ChannelTypeCard = ({ title, description, icon, disabled, onClick }: ChannelTypeCardProps) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +const BackButton = ({ onClick }: { onClick: () => void }) => { + const { t } = useTranslation(); + return ( + + + ); + })} + +
+
+ + + + {isPopupOpen && ( + <> +
+
+

{t('Add tags')}

+ } + label={t('Search a tag')} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + fullWidth + /> +
+
    + {labelsOptions.map((option) => ( +
  • + handleToggleTag(option.id)} + label={option.name} + /> +
  • + ))} +
  • + + +
  • +
+
+
setIsPopupOpen(false)}>
+ + )} + + ); +}; diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx new file mode 100644 index 00000000..0d7322dc --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-integration/widget-integration-form.tsx @@ -0,0 +1,245 @@ +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { Icon, IconType, IconSize } from "@gouvfr-lasuite/ui-kit"; +import { useTranslation } from "react-i18next"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { useState, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Channel, + useMailboxesChannelsCreate, + useMailboxesChannelsPartialUpdate, + getMailboxesChannelsListUrl, +} from "@/features/api/gen"; +import { useMailboxContext } from "@/features/providers/mailbox"; +import { RhfInput } from "@/features/forms/components/react-hook-form"; +import { addToast, ToasterItem } from "@/features/ui/components/toaster"; +import { Banner } from "@/features/ui/components/banner"; +import { handle } from "@/features/utils/errors"; +import { TagsSelector } from "./tags-selector"; + +type WidgetChannelSettings = { + tags?: string[]; + subject_template?: string; + config?: { enabled: boolean }; +}; + +type WidgetIntegrationFormProps = { + channel?: Channel; + onSuccess: (channel: Channel) => void; + onClose: () => void; +}; + +const createFormSchema = (t: (key: string) => string) => z.object({ + name: z.string().min(1, { message: t("Name is required.") }), + subject_template: z.string().min(1, { message: t("Subject template is required.") }), +}); + +type FormFields = z.infer>; + +export const WidgetIntegrationForm = ({ + channel, + onSuccess, + onClose, +}: WidgetIntegrationFormProps) => { + const { t } = useTranslation(); + const { selectedMailbox } = useMailboxContext(); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const widgetSettings = (channel?.settings as WidgetChannelSettings | undefined); + const [selectedTags, setSelectedTags] = useState( + widgetSettings?.tags || [] + ); + const isEditing = !!channel; + + const createMutation = useMailboxesChannelsCreate(); + const updateMutation = useMailboxesChannelsPartialUpdate(); + + const formSchema = useMemo(() => createFormSchema(t), [t]); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: channel?.name || "", + subject_template: widgetSettings?.subject_template || t("Message from {referer_domain}"), + }, + }); + + const { handleSubmit, formState: { errors } } = form; + + const invalidateChannels = async () => { + await queryClient.invalidateQueries({ + queryKey: [getMailboxesChannelsListUrl(selectedMailbox!.id)], + exact: false + }); + }; + + const onSubmit = async (data: FormFields) => { + setError(null); + + const settings = { + subject_template: data.subject_template, + tags: selectedTags, + config: { enabled: true }, + }; + + try { + if (isEditing && channel) { + // For updates, only send name and settings (not type) + await updateMutation.mutateAsync({ + mailboxId: selectedMailbox!.id, + id: channel.id, + data: { + name: data.name, + settings, + }, + }); + addToast( + + {t("Integration updated!")} + + ); + await invalidateChannels(); + } else { + // For creation, include type + const newChannel = await createMutation.mutateAsync({ + mailboxId: selectedMailbox!.id, + data: { + name: data.name, + type: "widget", + settings, + }, + }); + addToast( + + {t("Integration created!")} + + ); + await invalidateChannels(); + if (newChannel.status === 201) { + onSuccess(newChannel.data); + } + } + } catch (err) { + handle(err); + setError(t("An error occurred while saving the integration.")); + } + }; + + const widgetSnippet = channel ? ` +` : ""; + + return ( + +
+
+

{t("General")}

+ +
+ +
+

{t("Settings")}

+ + +
+ + {!isEditing && ( +
+ +

+ {t("After creating the widget, you will receive the installation code to add to your website.")} +

+
+ )} + + {error && ( + {error} + )} + +
+ + +
+ + {isEditing && channel && ( +
+

{t("Installation")}

+

+ {t("Add this code snippet to your website to display the feedback widget.")} +

+
+
{widgetSnippet}
+ +
+

+ + {t("View full documentation")} + + +

+
+ )} +
+
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx index 885c6f85..c4ede619 100644 --- a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx +++ b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { SearchInput } from "@/features/forms/components/search-input"; import useAbility, { Abilities } from "@/hooks/use-ability"; +import { useFeatureFlag, FEATURE_KEYS } from "@/hooks/use-feature"; import { useAuth, logout } from "@/features/auth"; import { LanguagePicker } from "@/features/layouts/components/main/language-picker"; import { LagaufreButton } from "@/features/ui/components/lagaufre"; @@ -89,6 +90,7 @@ const ApplicationMenu = () => { const canAccessDomainAdmin = useAbility(Abilities.CAN_VIEW_DOMAIN_ADMIN); const canImportMessages = useAbility(Abilities.CAN_IMPORT_MESSAGES, selectedMailbox); const canManageMessageTemplates = useAbility(Abilities.CAN_MANAGE_MESSAGE_TEMPLATES, selectedMailbox); + const isIntegrationsEnabled = useFeatureFlag(FEATURE_KEYS.MAILBOX_ADMIN_CHANNELS); const { t } = useTranslation(); const router = useRouter(); const taskId = useMemo(() => { @@ -155,6 +157,15 @@ const ApplicationMenu = () => { } } }] : []), + ...(canManageMessageTemplates && isIntegrationsEnabled ? [{ + label: t("Integrations"), + icon: , + callback: () => { + if (selectedMailbox) { + router.push(`/mailbox/${selectedMailbox.id}/integrations`); + } + } + }] : []), ]} >