(integrations) add integrations view in mailbox (#488)

For now this allows users to create feedback widgets linked to their
mailbox. We will also have API keys and recurring imports there.
This commit is contained in:
Sylvain Zimmer
2026-01-25 17:29:19 +01:00
committed by GitHub
parent bd76a081e9
commit e6296f9b3a
33 changed files with 3401 additions and 11 deletions
+1
View File
@@ -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
+421
View File
@@ -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.",
+76 -3
View File
@@ -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")
+103
View File
@@ -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)
+11
View File
@@ -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
@@ -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(),
+19 -2
View File
@@ -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:
+423
View File
@@ -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"
@@ -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,
@@ -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(
+16
View File
@@ -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/<uuid:mailbox_id>/",
include(
mailbox_channel_nested_router.urls
), # Includes /mailboxes/{id}/channels/
),
path(
"maildomains/<uuid:maildomain_pk>/",
include(maildomain_nested_router.urls),
+3
View File
@@ -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
@@ -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 <strong>{{mailboxAddress}}</strong> has been created successfully.": "The shared mailbox <strong>{{mailboxAddress}}</strong> 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 <strong>{{domain}}</strong> 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 <strong>{{domain}}</strong> 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.",
+46 -1
View File
@@ -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 sest 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 <strong>{{mailboxAddress}}</strong> has been created successfully.": "L'adresse partagée <strong>{{mailboxAddress}}</strong> 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 <strong>{{domain}}</strong> 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 <strong>{{domain}}</strong> 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.",
@@ -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<T extends (...args: never) => unknown> = Parameters<T>[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<mailboxesChannelsListResponse> => {
return fetchAPI<mailboxesChannelsListResponse>(
getMailboxesChannelsListUrl(mailboxId),
{
...options,
method: "GET",
},
);
};
export const getMailboxesChannelsListQueryKey = (mailboxId?: string) => {
return [`/api/v1.0/mailboxes/${mailboxId}/channels/`] as const;
};
export const getMailboxesChannelsListQueryOptions = <
TData = Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError = unknown,
>(
mailboxId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getMailboxesChannelsListQueryKey(mailboxId);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof mailboxesChannelsList>>
> = ({ signal }) =>
mailboxesChannelsList(mailboxId, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!mailboxId,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type MailboxesChannelsListQueryResult = NonNullable<
Awaited<ReturnType<typeof mailboxesChannelsList>>
>;
export type MailboxesChannelsListQueryError = unknown;
export function useMailboxesChannelsList<
TData = Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError = unknown,
>(
mailboxId: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
Awaited<ReturnType<typeof mailboxesChannelsList>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesChannelsList<
TData = Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError = unknown,
>(
mailboxId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
Awaited<ReturnType<typeof mailboxesChannelsList>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesChannelsList<
TData = Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError = unknown,
>(
mailboxId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesChannelsList<
TData = Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError = unknown,
>(
mailboxId: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsList>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getMailboxesChannelsListQueryOptions(mailboxId, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<mailboxesChannelsCreateResponse> => {
return fetchAPI<mailboxesChannelsCreateResponse>(
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<ReturnType<typeof mailboxesChannelsCreate>>,
TError,
{ mailboxId: string; data: ChannelRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsCreate>>,
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<ReturnType<typeof mailboxesChannelsCreate>>,
{ mailboxId: string; data: ChannelRequest }
> = (props) => {
const { mailboxId, data } = props ?? {};
return mailboxesChannelsCreate(mailboxId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type MailboxesChannelsCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof mailboxesChannelsCreate>>
>;
export type MailboxesChannelsCreateMutationBody = ChannelRequest;
export type MailboxesChannelsCreateMutationError = void;
export const useMailboxesChannelsCreate = <TError = void, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsCreate>>,
TError,
{ mailboxId: string; data: ChannelRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof mailboxesChannelsCreate>>,
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<mailboxesChannelsRetrieveResponse> => {
return fetchAPI<mailboxesChannelsRetrieveResponse>(
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<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError = unknown,
>(
mailboxId: string,
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getMailboxesChannelsRetrieveQueryKey(mailboxId, id);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>
> = ({ signal }) =>
mailboxesChannelsRetrieve(mailboxId, id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!(mailboxId && id),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type MailboxesChannelsRetrieveQueryResult = NonNullable<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>
>;
export type MailboxesChannelsRetrieveQueryError = unknown;
export function useMailboxesChannelsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError = unknown,
>(
mailboxId: string,
id: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesChannelsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError = unknown,
>(
mailboxId: string,
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesChannelsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError = unknown,
>(
mailboxId: string,
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesChannelsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError = unknown,
>(
mailboxId: string,
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesChannelsRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getMailboxesChannelsRetrieveQueryOptions(
mailboxId,
id,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<mailboxesChannelsUpdateResponse> => {
return fetchAPI<mailboxesChannelsUpdateResponse>(
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<ReturnType<typeof mailboxesChannelsUpdate>>,
TError,
{ mailboxId: string; id: string; data: ChannelRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsUpdate>>,
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<ReturnType<typeof mailboxesChannelsUpdate>>,
{ 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<ReturnType<typeof mailboxesChannelsUpdate>>
>;
export type MailboxesChannelsUpdateMutationBody = ChannelRequest;
export type MailboxesChannelsUpdateMutationError = void;
export const useMailboxesChannelsUpdate = <TError = void, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsUpdate>>,
TError,
{ mailboxId: string; id: string; data: ChannelRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof mailboxesChannelsUpdate>>,
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<mailboxesChannelsPartialUpdateResponse> => {
return fetchAPI<mailboxesChannelsPartialUpdateResponse>(
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<ReturnType<typeof mailboxesChannelsPartialUpdate>>,
TError,
{ mailboxId: string; id: string; data: PatchedChannelRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsPartialUpdate>>,
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<ReturnType<typeof mailboxesChannelsPartialUpdate>>,
{ 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<ReturnType<typeof mailboxesChannelsPartialUpdate>>
>;
export type MailboxesChannelsPartialUpdateMutationBody = PatchedChannelRequest;
export type MailboxesChannelsPartialUpdateMutationError = unknown;
export const useMailboxesChannelsPartialUpdate = <
TError = unknown,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsPartialUpdate>>,
TError,
{ mailboxId: string; id: string; data: PatchedChannelRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof mailboxesChannelsPartialUpdate>>,
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<mailboxesChannelsDestroyResponse> => {
return fetchAPI<mailboxesChannelsDestroyResponse>(
getMailboxesChannelsDestroyUrl(mailboxId, id),
{
...options,
method: "DELETE",
},
);
};
export const getMailboxesChannelsDestroyMutationOptions = <
TError = void,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsDestroy>>,
TError,
{ mailboxId: string; id: string },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsDestroy>>,
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<ReturnType<typeof mailboxesChannelsDestroy>>,
{ mailboxId: string; id: string }
> = (props) => {
const { mailboxId, id } = props ?? {};
return mailboxesChannelsDestroy(mailboxId, id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type MailboxesChannelsDestroyMutationResult = NonNullable<
Awaited<ReturnType<typeof mailboxesChannelsDestroy>>
>;
export type MailboxesChannelsDestroyMutationError = void;
export const useMailboxesChannelsDestroy = <TError = void, TContext = unknown>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mailboxesChannelsDestroy>>,
TError,
{ mailboxId: string; id: string },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof mailboxesChannelsDestroy>>,
TError,
{ mailboxId: string; id: string },
TContext
> => {
const mutationOptions = getMailboxesChannelsDestroyMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
@@ -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";
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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";
@@ -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;
}
@@ -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 (
<>
<Button
onClick={() => modal.open()}
icon={<Icon name="add" />}
>
{t("New integration")}
</Button>
<ModalComposeIntegration
isOpen={modal.isOpen}
onClose={() => modal.close()}
/>
</>
);
};
@@ -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<Channel | undefined>();
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: <span className="c__modal__text--centered">{t('Delete integration "{{name}}"', { name: channel.name })}</span>,
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(
<ToasterItem type="info">
<span>{t("Integration deleted!")}</span>
</ToasterItem>,
);
} catch (error) {
handle(error);
addToast(
<ToasterItem type="error">
<span>{t("Failed to delete integration.")}</span>
</ToasterItem>,
);
}
}
}
const columns: Column<Channel>[] = [
{
id: "name",
headerName: t("Name"),
renderCell: ({ row }) => (
<div className="flex-row flex-align-center" style={{ gap: "var(--c--globals--spacings--xs)" }}>
<Icon name={getChannelTypeIcon(row.type)} type={IconType.OUTLINED} size={IconSize.SMALL} />
<span>{row.name}</span>
</div>
),
},
{
id: "type",
headerName: t("Type"),
size: 150,
renderCell: ({ row }) => getChannelTypeLabel(row.type, t),
},
{
id: "actions",
size: 154,
headerName: t("Actions"),
renderCell: ({ row }) => (
<div className="flex-row flex-justify-start" style={{ width: "100%", gap: "var(--c--globals--spacings--2xs)" }}>
<Button
variant="bordered"
size="small"
onClick={() => handleModifyRow(row)}
>
{t("Modify")}
</Button>
<Button
color="error"
size="small"
onClick={() => handleDeleteRow(row)}
disabled={isDeleting}
icon={isDeleting ? <Spinner size="sm" /> : <Icon name="delete" size={IconSize.SMALL} />}
aria-label={t("Delete")}
/>
</div>
),
},
];
if (isLoading) {
return (
<div className="admin-data-grid">
<Banner type="info" icon={<Spinner />}>
{t("Loading integrations...")}
</Banner>
</div>
);
}
if (error) {
return (
<div className="admin-data-grid">
<Banner type="error">
{t("Error while loading integrations")}
</Banner>
</div>
);
}
return (
<section className="admin-page__body">
<div className="admin-data-grid">
<DataGrid
columns={columns}
rows={channels?.data ?? []}
onSortModelChange={() => undefined}
enableSorting={false}
emptyPlaceholderLabel={t("No integration found")}
/>
<ModalComposeIntegration
isOpen={modal.isOpen}
onClose={() => {
modal.close();
setSelectedChannel(undefined);
}}
channel={selectedChannel}
onSuccess={invalidateChannels}
/>
</div>
</section>
);
};
@@ -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 (
<div className="admin-page__content">
<IntegrationsDataGrid mailbox={selectedMailbox} />
</div>
);
};
@@ -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;
}
@@ -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<ChannelType, ChannelTypeMetadata> = {
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 (
<button
type="button"
className={`channel-type-card ${disabled ? "channel-type-card--disabled" : ""}`}
onClick={onClick}
disabled={disabled}
>
{disabled && (
<span className="channel-type-card__badge">{t("Coming soon")}</span>
)}
<div className="channel-type-card__icon">
<Icon name={icon} type={IconType.OUTLINED} size={IconSize.LARGE} />
</div>
<div className="channel-type-card__content">
<h3 className="channel-type-card__title">{title}</h3>
<p className="channel-type-card__description">{description}</p>
</div>
</button>
);
};
const BackButton = ({ onClick }: { onClick: () => void }) => {
const { t } = useTranslation();
return (
<Button
type="button"
variant="tertiary"
size="small"
icon={<Icon name="arrow_back" type={IconType.OUTLINED} />}
onClick={onClick}
aria-label={t("Back")}
/>
);
};
export const ModalComposeIntegration = ({
isOpen,
onClose,
channel: initialChannel,
onSuccess,
}: ModalComposeIntegrationProps) => {
const { t } = useTranslation();
const config = useConfig();
const [currentChannel, setCurrentChannel] = useState<Channel | undefined>(initialChannel);
const isEditing = !!currentChannel;
const [viewState, setViewState] = useState<ViewState>(isEditing ? "form" : "select_type");
const [selectedType, setSelectedType] = useState<ChannelType | null>(
currentChannel?.type as ChannelType | null
);
const enabledChannelTypes = (config.FEATURE_MAILBOX_ADMIN_CHANNELS || []) as string[];
// Reset state when modal opens/closes or channel changes
useEffect(() => {
if (isOpen) {
if (initialChannel) {
setCurrentChannel(initialChannel);
setViewState("form");
setSelectedType(initialChannel.type as ChannelType);
} else {
setCurrentChannel(undefined);
setViewState("select_type");
setSelectedType(null);
}
}
}, [isOpen, initialChannel]);
const handleSelectType = (type: ChannelType) => {
setSelectedType(type);
setViewState("form");
};
const handleBack = () => {
setViewState("select_type");
setSelectedType(null);
};
const handleSuccess = (newChannel: Channel) => {
// When a new channel is created, switch to edit mode
setCurrentChannel(newChannel);
onSuccess?.();
};
const getTitle = () => {
if (viewState === "select_type") {
return t("Create a new integration");
}
if (selectedType === "widget") {
return isEditing ? t("Edit Widget") : t("Create a Widget");
}
return t("Integrations");
};
// Show back button only when in form view after selecting a type (not when editing existing)
const showBackButton = viewState === "form" && !isEditing;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={getTitle()}
size={ModalSize.LARGE}
leftActions={showBackButton ? <BackButton onClick={handleBack} /> : undefined}
>
<div className="modal-compose-integration">
{viewState === "select_type" && (
<div className="channel-type-selector">
<p className="channel-type-selector__subtitle">
{t("Choose the type of integration you want to create")}
</p>
<div className="channel-type-selector__cards">
{enabledChannelTypes.map((channelType) => {
const metadata = CHANNEL_TYPE_METADATA[channelType as ChannelType];
if (!metadata) return null;
return (
<ChannelTypeCard
key={channelType}
title={t(metadata.title)}
description={t(metadata.description)}
icon={metadata.icon}
onClick={() => handleSelectType(metadata.type)}
disabled={metadata.disabled}
/>
);
})}
</div>
</div>
)}
{viewState === "form" && selectedType === "widget" && (
<WidgetIntegrationForm
channel={currentChannel}
onSuccess={handleSuccess}
onClose={onClose}
/>
)}
</div>
</Modal>
);
};
@@ -0,0 +1,219 @@
import { TreeLabel, ThreadLabel, useLabelsList } from "@/features/api/gen";
import { Icon, IconType, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { Button, Checkbox, Field, Input, LabelledBox, useModal } from "@gouvfr-lasuite/cunningham-react";
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useMailboxContext } from "@/features/providers/mailbox";
import StringHelper from "@/features/utils/string-helper";
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
import { Badge } from "@/features/ui/components/badge";
import { ColorHelper } from "@/features/utils/color-helper";
type TagsSelectorProps = {
selectedTags: string[];
onTagsChange: (tags: string[]) => void;
};
// Convert TreeLabel to ThreadLabel format for display
const treeToThreadLabel = (label: TreeLabel): ThreadLabel => ({
id: label.id,
name: label.name,
slug: label.slug,
color: label.color ?? undefined,
display_name: label.display_name,
description: label.description ?? undefined,
is_auto: label.is_auto,
});
// Flatten tree labels into a list with all nested children
const flattenLabels = (labels: TreeLabel[]): TreeLabel[] => {
const result: TreeLabel[] = [];
const flatten = (label: TreeLabel) => {
result.push(label);
label.children.forEach(flatten);
};
labels.forEach(flatten);
return result;
};
export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps) => {
const { t } = useTranslation();
const { selectedMailbox, invalidateLabels } = useMailboxContext();
const { open, close, isOpen } = useModal();
const [searchQuery, setSearchQuery] = useState('');
const [isPopupOpen, setIsPopupOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { data: labelsList, isLoading } = useLabelsList(
{ mailbox_id: selectedMailbox?.id ?? '' },
{ query: { enabled: !!selectedMailbox } }
);
const allLabels = useMemo(() => flattenLabels(labelsList?.data || []), [labelsList?.data]);
const selectedLabelsAsThreadLabels = useMemo(() => {
return allLabels
.filter((label) => selectedTags.includes(label.id))
.map(treeToThreadLabel);
}, [allLabels, selectedTags]);
const labelsOptions = useMemo(() => {
return allLabels
.map((label) => ({
...treeToThreadLabel(label),
checked: selectedTags.includes(label.id),
}))
.filter((option) => {
const normalizedLabel = StringHelper.normalizeForSearch(option.name);
const normalizedSearchQuery = StringHelper.normalizeForSearch(searchQuery);
return normalizedLabel.includes(normalizedSearchQuery);
})
.sort((a, b) => {
if (a.checked !== b.checked) return a.checked ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [allLabels, selectedTags, searchQuery]);
const handleToggleTag = (tagId: string) => {
if (selectedTags.includes(tagId)) {
onTagsChange(selectedTags.filter((id) => id !== tagId));
} else {
onTagsChange([...selectedTags, tagId]);
}
};
const handleRemoveTag = (tagId: string) => {
onTagsChange(selectedTags.filter((id) => id !== tagId));
};
const handleCreateLabel = (label: { id: string }) => {
onTagsChange([...selectedTags, label.id]);
invalidateLabels();
};
const showLabelAsPlaceholder = selectedLabelsAsThreadLabels.length === 0;
if (isLoading) {
return (
<div className="tags-selector tags-selector--loading">
<Spinner size="sm" />
<span>{t('Loading tags...')}</span>
</div>
);
}
return (
<Field
className="tags-selector"
text={t('These tags will be automatically applied to every incoming message from the widget.')}
>
<div
ref={containerRef}
className="tags-selector__wrapper"
onClick={() => setIsPopupOpen(true)}
>
<LabelledBox
label={t('Tags')}
labelAsPlaceholder={showLabelAsPlaceholder}
>
<div className="tags-selector__value">
{selectedLabelsAsThreadLabels.map((label) => {
const badgeColor = label.color
? ColorHelper.getContrastColor(label.color, {
lightColor: '#fff',
darkColor: '#000'
})
: undefined;
return (
<Badge
key={label.id}
className="label-badge label-badge--compact"
style={label.color ? { backgroundColor: label.color, color: badgeColor } : undefined}
>
<span className="label-badge__label">{label.name}</span>
<button
type="button"
className="label-badge__remove-cta"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(label.id);
}}
aria-label={t('Remove tag')}
>
<Icon name="close" size={IconSize.SMALL} type={IconType.OUTLINED} />
</button>
</Badge>
);
})}
</div>
<div className="c__select__inner__actions">
<Button
type="button"
variant="tertiary"
size="nano"
onClick={(e) => {
e.stopPropagation();
setIsPopupOpen(true);
}}
icon={<Icon name="new_label" type={IconType.OUTLINED} />}
aria-label={t('Add tags')}
/>
</div>
</LabelledBox>
</div>
{isPopupOpen && (
<>
<div className="thread-labels-widget__popup tags-selector__popup">
<header className="thread-labels-widget__popup__header">
<h3><Icon type={IconType.OUTLINED} name="new_label" /> {t('Add tags')}</h3>
<Input
className="thread-labels-widget__popup__search"
type="search"
icon={<Icon type={IconType.OUTLINED} name="search" />}
label={t('Search a tag')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
fullWidth
/>
</header>
<ul className="thread-labels-widget__popup__content">
{labelsOptions.map((option) => (
<li key={option.id}>
<Checkbox
checked={option.checked}
onChange={() => handleToggleTag(option.id)}
label={option.name}
/>
</li>
))}
<li className="thread-labels-widget__popup__content__empty">
<Button
type="button"
color="brand"
variant="primary"
onClick={open}
fullWidth
icon={<Icon type={IconType.OUTLINED} name="add" />}
>
<span className="thread-labels-widget__popup__content__empty__button-label">
{searchQuery && labelsOptions.length === 0
? t('Create the label "{{label}}"', { label: searchQuery })
: t('Create a new label')}
</span>
</Button>
<LabelModal
isOpen={isOpen}
onClose={close}
label={{ display_name: searchQuery }}
onSuccess={handleCreateLabel}
/>
</li>
</ul>
</div>
<div className="thread-labels-widget__popup__overlay" onClick={() => setIsPopupOpen(false)}></div>
</>
)}
</Field>
);
};
@@ -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<ReturnType<typeof createFormSchema>>;
export const WidgetIntegrationForm = ({
channel,
onSuccess,
onClose,
}: WidgetIntegrationFormProps) => {
const { t } = useTranslation();
const { selectedMailbox } = useMailboxContext();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const widgetSettings = (channel?.settings as WidgetChannelSettings | undefined);
const [selectedTags, setSelectedTags] = useState<string[]>(
widgetSettings?.tags || []
);
const isEditing = !!channel;
const createMutation = useMailboxesChannelsCreate();
const updateMutation = useMailboxesChannelsPartialUpdate();
const formSchema = useMemo(() => createFormSchema(t), [t]);
const form = useForm<FormFields>({
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(
<ToasterItem type="info">
<span>{t("Integration updated!")}</span>
</ToasterItem>
);
await invalidateChannels();
} else {
// For creation, include type
const newChannel = await createMutation.mutateAsync({
mailboxId: selectedMailbox!.id,
data: {
name: data.name,
type: "widget",
settings,
},
});
addToast(
<ToasterItem type="info">
<span>{t("Integration created!")}</span>
</ToasterItem>
);
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 ? `<script src="${process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH}loader.js" async></script>
<script>
window._lasuite_widget = window._lasuite_widget || [];
_lasuite_widget.push(["loader", "init", {
"params": {
"api": "${process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_API_URL}",
"channel": "${channel.id}"
},
"script": "${process.env.NEXT_PUBLIC_FEEDBACK_WIDGET_PATH}feedback.js",
"widget": "feedback",
}]);
</script>` : "";
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="widget-integration-form">
<div className="widget-integration-form__section">
<h3>{t("General")}</h3>
<RhfInput
label={t("Name")}
name="name"
text={errors.name?.message || t("This name is for internal use only and will not be visible to users.")}
state={errors.name ? "error" : "default"}
fullWidth
/>
</div>
<div className="widget-integration-form__section">
<h3>{t("Settings")}</h3>
<RhfInput
label={t("Subject template")}
name="subject_template"
text={errors.subject_template?.message || t("Use {referer_domain} to include the website domain in the subject.")}
state={errors.subject_template ? "error" : "default"}
fullWidth
/>
<TagsSelector
selectedTags={selectedTags}
onTagsChange={setSelectedTags}
/>
</div>
{!isEditing && (
<div className="widget-integration-form__section widget-integration-form__section--info">
<Icon name="info" type={IconType.OUTLINED} />
<p>
{t("After creating the widget, you will receive the installation code to add to your website.")}
</p>
</div>
)}
{error && (
<Banner type="error">{error}</Banner>
)}
<div className="widget-integration-form__actions">
<Button type="button" variant="secondary" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
>
{isEditing ? t("Save changes") : t("Create integration")}
</Button>
</div>
{isEditing && channel && (
<div className="widget-integration-form__section">
<h3>{t("Installation")}</h3>
<p className="widget-integration-form__section-description">
{t("Add this code snippet to your website to display the feedback widget.")}
</p>
<div className="widget-integration-form__snippet">
<pre><code>{widgetSnippet}</code></pre>
<Button
type="button"
variant="tertiary"
size="small"
icon={<Icon name="content_copy" type={IconType.OUTLINED} />}
onClick={async () => {
try {
await navigator.clipboard.writeText(widgetSnippet);
addToast(
<ToasterItem type="info">
<span>{t("Copied to clipboard")}</span>
</ToasterItem>
);
} catch {
addToast(
<ToasterItem type="error">
<span>{t("Unable to copy to clipboard.")}</span>
</ToasterItem>
);
}
}}
>
{t("Copy")}
</Button>
</div>
<p className="widget-integration-form__doc-link">
<a
href="https://integration.lasuite.numerique.gouv.fr/guides/feedback/"
target="_blank"
rel="noopener noreferrer"
>
{t("View full documentation")}
<Icon name="open_in_new" type={IconType.OUTLINED} size={IconSize.SMALL} />
</a>
</p>
</div>
)}
</form>
</FormProvider>
);
};
@@ -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: <Icon name="integration_instructions" type={IconType.OUTLINED} />,
callback: () => {
if (selectedMailbox) {
router.push(`/mailbox/${selectedMailbox.id}/integrations`);
}
}
}] : []),
]}
>
<Button
@@ -18,6 +18,7 @@ const DEFAULT_CONFIG: AppConfig = {
AI_ENABLED: false,
FEATURE_AI_SUMMARY: false,
FEATURE_AI_AUTOLABELS: false,
FEATURE_MAILBOX_ADMIN_CHANNELS: [],
SCHEMA_CUSTOM_ATTRIBUTES_USER: {},
SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN: {},
MAX_OUTGOING_ATTACHMENT_SIZE: 0,
+3
View File
@@ -4,6 +4,7 @@ export enum FEATURE_KEYS {
DRIVE = 'drive',
AI_SUMMARY = 'ai_summary',
AI_AUTOLABELS = 'ai_autolabels',
MAILBOX_ADMIN_CHANNELS = 'mailbox_admin_channels',
}
/**
@@ -23,6 +24,8 @@ export const useFeatureFlag = (featureKey: FEATURE_KEYS) => {
return config.AI_ENABLED === true && config.FEATURE_AI_SUMMARY === true;
case FEATURE_KEYS.AI_AUTOLABELS:
return config.AI_ENABLED === true && config.FEATURE_AI_AUTOLABELS === true;
case FEATURE_KEYS.MAILBOX_ADMIN_CHANNELS:
return Array.isArray(config.FEATURE_MAILBOX_ADMIN_CHANNELS) && config.FEATURE_MAILBOX_ADMIN_CHANNELS.length > 0;
default:
throw new Error(`Unknown feature key: ${featureKey}`);
}
@@ -0,0 +1,52 @@
import { MainLayout } from "@/features/layouts/components/main";
import { useEffect } from "react";
import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { useMailboxContext } from "@/features/providers/mailbox";
import { IntegrationsPageContent } from "@/features/layouts/components/mailbox-settings/integrations-view/page-content";
import { CreateIntegrationAction } from "@/features/layouts/components/mailbox-settings/integrations-view/create-integration-action";
import { useFeatureFlag, FEATURE_KEYS } from "@/hooks/use-feature";
const MailboxIntegrationsPage = () => {
const { t } = useTranslation();
const router = useRouter();
const { queryStates, selectedMailbox } = useMailboxContext();
const isIntegrationsEnabled = useFeatureFlag(FEATURE_KEYS.MAILBOX_ADMIN_CHANNELS);
useEffect(() => {
if (!queryStates.mailboxes.isLoading && !selectedMailbox) {
router.push("/");
}
}, [queryStates.mailboxes.isLoading, selectedMailbox, router]);
useEffect(() => {
if (!isIntegrationsEnabled) {
router.push("/");
}
}, [isIntegrationsEnabled, router]);
if (!isIntegrationsEnabled) {
return null;
}
return (
<div className="admin-page">
<div className="admin-page__header">
<h1 className="title">{t("Integrations")}</h1>
<div className="admin-page__actions">
<CreateIntegrationAction />
</div>
</div>
<div className="admin-page__content">
{selectedMailbox && <IntegrationsPageContent />}
</div>
</div>
);
}
MailboxIntegrationsPage.getLayout = (page: React.ReactElement) => {
return <MainLayout>{page}</MainLayout>;
};
export default MailboxIntegrationsPage;
+1
View File
@@ -45,6 +45,7 @@
@use "./../features/forms/components/search-input";
@use "./../features/forms/components/search-filters-form";
@use "./../features/controlled-modals/message-importer";
@use "./../features/layouts/components/mailbox-settings/modal-compose-integration";
@use "./../features/layouts/components/admin/mailbox-credentials";
@use "./../features/layouts/components/admin/modal-create-update-mailbox";
@use "./../features/layouts/components/admin/modal-create-domain";