diff --git a/env.d/development/backend.e2e b/env.d/development/backend.e2e index 8db619e30..556344929 100644 --- a/env.d/development/backend.e2e +++ b/env.d/development/backend.e2e @@ -32,3 +32,7 @@ EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend # Debug DJANGO_DEBUG=True + +# Max recipients per message settings for E2E tests +MAX_RECIPIENTS_PER_MESSAGE=200 +MAX_DEFAULT_RECIPIENTS_PER_MESSAGE=150 diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index c6df6e437..0f2f523f8 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -222,6 +222,16 @@ "type": "integer", "description": "Maximum size in bytes for incoming email (including attachments and body)", "readOnly": true + }, + "MAX_RECIPIENTS_PER_MESSAGE": { + "type": "integer", + "description": "Maximum number of recipients per message (to + cc + bcc) for the entire system. Cannot be exceeded.", + "readOnly": true + }, + "MAX_DEFAULT_RECIPIENTS_PER_MESSAGE": { + "type": "integer", + "description": "Default maximum number of recipients per message (to + cc + bcc) for a mailbox or maildomain if no custom limit is set.", + "readOnly": true } }, "required": [ @@ -235,7 +245,9 @@ "SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN", "MAX_OUTGOING_ATTACHMENT_SIZE", "MAX_OUTGOING_BODY_SIZE", - "MAX_INCOMING_EMAIL_SIZE" + "MAX_INCOMING_EMAIL_SIZE", + "MAX_RECIPIENTS_PER_MESSAGE", + "MAX_DEFAULT_RECIPIENTS_PER_MESSAGE" ] } } @@ -3027,6 +3039,106 @@ "description": "" } } + }, + "put": { + "operationId": "maildomains_update", + "description": "ViewSet for listing MailDomains the user administers.\nProvides a top-level entry for mail domain administration.\nEndpoint: /maildomains//", + "parameters": [ + { + "in": "path", + "name": "maildomain_pk", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + } + ], + "tags": [ + "maildomains" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailDomainAdminUpdateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MailDomainAdminUpdateRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailDomainAdminUpdate" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "maildomains_partial_update", + "description": "ViewSet for listing MailDomains the user administers.\nProvides a top-level entry for mail domain administration.\nEndpoint: /maildomains//", + "parameters": [ + { + "in": "path", + "name": "maildomain_pk", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + } + ], + "tags": [ + "maildomains" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMailDomainAdminUpdateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMailDomainAdminUpdateRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailDomainAdminUpdate" + } + } + }, + "description": "" + } + } } }, "/api/v1.0/maildomains/{maildomain_pk}/accesses/": { @@ -3343,9 +3455,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3388,9 +3498,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3447,9 +3555,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3487,9 +3593,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3555,6 +3659,71 @@ } } }, + "/api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/settings/": { + "patch": { + "operationId": "maildomains_mailboxes_settings_update", + "description": "Update mailbox settings (custom_limits).", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "maildomain_pk", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "maildomains" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedMailboxSettingsUpdateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedMailboxSettingsUpdateRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxSettingsUpdate" + } + } + }, + "description": "Mailbox settings updated successfully." + }, + "400": { + "description": "Invalid settings data." + }, + "403": { + "description": "User does not have permission to manage settings." + } + } + } + }, "/api/v1.0/maildomains/{maildomain_pk}/message-templates/": { "get": { "operationId": "maildomains_message_templates_list", @@ -5542,6 +5711,19 @@ "type": "string", "readOnly": true }, + "custom_limits": { + "type": "object", + "properties": { + "max_recipients_per_message": { + "type": "integer", + "nullable": true, + "description": "Maximum number of recipients per message for this domain." + } + }, + "nullable": true, + "readOnly": true, + "description": "Limits applied to this mail domain (e.g. max recipients per message)." + }, "abilities": { "type": "object", "description": "Instance permissions and capabilities", @@ -5573,6 +5755,10 @@ "manage_mailboxes": { "type": "boolean", "description": "Can manage mailboxes" + }, + "manage_settings": { + "type": "boolean", + "description": "Can manage settings" } }, "required": [ @@ -5582,7 +5768,8 @@ "patch", "delete", "manage_accesses", - "manage_mailboxes" + "manage_mailboxes", + "manage_settings" ], "readOnly": true } @@ -5590,15 +5777,43 @@ "required": [ "abilities", "created_at", + "custom_limits", "expected_dns_records", "id", "name", "updated_at" ] }, + "MailDomainAdminUpdate": { + "type": "object", + "description": "Serialize mail domains for updating custom_limits only.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "custom_limits": { + "description": "Limits applied to this mail domain (e.g. max recipients per message)." + } + }, + "required": [ + "id" + ] + }, + "MailDomainAdminUpdateRequest": { + "type": "object", + "description": "Serialize mail domains for updating custom_limits only.", + "properties": { + "custom_limits": { + "description": "Limits applied to this mail domain (e.g. max recipients per message)." + } + } + }, "MailDomainAdminWrite": { "type": "object", - "description": "Serialize mail domains for creating / editing admin view.", + "description": "Serialize mail domains for creating admin view.", "properties": { "id": { "type": "string", @@ -5646,7 +5861,7 @@ }, "MailDomainAdminWriteRequest": { "type": "object", - "description": "Serialize mail domains for creating / editing admin view.", + "description": "Serialize mail domains for creating admin view.", "properties": { "name": { "type": "string", @@ -5700,6 +5915,10 @@ "type": "string", "readOnly": true }, + "max_recipients_per_message": { + "type": "integer", + "readOnly": true + }, "abilities": { "type": "object", "description": "Instance permissions and capabilities", @@ -5747,6 +5966,10 @@ "import_messages": { "type": "boolean", "description": "Can import messages" + }, + "manage_settings": { + "type": "boolean", + "description": "Can manage settings" } }, "required": [ @@ -5760,7 +5983,8 @@ "send_messages", "manage_labels", "manage_message_templates", - "import_messages" + "import_messages", + "manage_settings" ], "readOnly": true } @@ -5771,6 +5995,7 @@ "count_unread_messages", "email", "id", + "max_recipients_per_message", "role" ] }, @@ -5977,6 +6202,19 @@ } ], "readOnly": true + }, + "custom_limits": { + "type": "object", + "properties": { + "max_recipients_per_message": { + "type": "integer", + "nullable": true, + "description": "Maximum number of recipients per message for this mailbox." + } + }, + "nullable": true, + "readOnly": true, + "description": "Limits applied to this mailbox (e.g. max recipients per message)." } }, "required": [ @@ -5984,6 +6222,7 @@ "can_reset_password", "contact", "created_at", + "custom_limits", "domain_name", "id", "is_identity", @@ -5993,7 +6232,7 @@ }, "MailboxAdminCreate": { "type": "object", - "description": "Serialize Mailbox details for create admin endpoint, including users with access and\nmetadata.", + "description": "Serialize Mailbox details for create admin endpoint, including users with access and\nmetadata.\n\nNote: custom_limits is excluded from the response as it cannot be set during creation.\nIt can only be modified via the dedicated /settings/ endpoint.", "properties": { "id": { "type": "string", @@ -6123,7 +6362,8 @@ "name": { "type": "string" }, - "custom_attributes": {} + "custom_attributes": {}, + "custom_limits": {} } }, "MailboxLight": { @@ -6160,6 +6400,24 @@ "admin" ] }, + "MailboxSettingsUpdate": { + "type": "object", + "description": "Serialize mailbox settings (custom_limits) for update operations.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "custom_limits": { + "description": "Limits applied to this mailbox (e.g. max recipients per message)." + } + }, + "required": [ + "id" + ] + }, "MaildomainAccessRead": { "type": "object", "description": "Serialize maildomain access information for read operations with nested user details.", @@ -6861,6 +7119,15 @@ } } }, + "PatchedMailDomainAdminUpdateRequest": { + "type": "object", + "description": "Serialize mail domains for updating custom_limits only.", + "properties": { + "custom_limits": { + "description": "Limits applied to this mail domain (e.g. max recipients per message)." + } + } + }, "PatchedMailboxAccessWriteRequest": { "type": "object", "description": "Serializer for creating and updating mailbox access records.\nMailbox is set from the view based on URL parameters.", @@ -6883,6 +7150,15 @@ } } }, + "PatchedMailboxSettingsUpdateRequest": { + "type": "object", + "description": "Serialize mailbox settings (custom_limits) for update operations.", + "properties": { + "custom_limits": { + "description": "Limits applied to this mailbox (e.g. max recipients per message)." + } + } + }, "PatchedMessageTemplateRequest": { "type": "object", "description": "Serialize message templates for POST/PUT/PATCH operations.", diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 4b5d860cf..8deda8701 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -450,3 +450,33 @@ def has_permission(self, request, view): return models.MailboxAccess.objects.filter( user=request.user, mailbox=view.kwargs.get("mailbox_id") ).exists() + + +class CanManageSettings(permissions.BasePermission): + """ + Permission class that checks if the user has CAN_MANAGE_SETTINGS ability + for the given MailDomain or Mailbox object. + + This permission should be used for actions that modify custom_limits and other settings. + """ + + message = "You do not have permission to manage settings for this resource." + + def has_object_permission(self, request, view, obj): + """ + Check if user has CAN_MANAGE_SETTINGS ability for the object. + Works with both MailDomain and Mailbox objects. + """ + if not request.user or not request.user.is_authenticated: + return False + + # Get abilities for this object + abilities = obj.get_abilities(request.user) + + # Check for the specific ability + if isinstance(obj, models.MailDomain): + return abilities[enums.MailDomainAbilities.CAN_MANAGE_SETTINGS] + if isinstance(obj, models.Mailbox): + return abilities[enums.MailboxAbilities.CAN_MANAGE_SETTINGS] + + return False diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d89674a11..db1a05315 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -4,6 +4,7 @@ import json +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 _ @@ -216,10 +217,18 @@ class MailboxSerializer(AbilitiesModelSerializer): role = serializers.SerializerMethodField(read_only=True) count_unread_messages = serializers.SerializerMethodField(read_only=True) count_messages = serializers.SerializerMethodField(read_only=True) + max_recipients_per_message = serializers.SerializerMethodField(read_only=True) class Meta: model = models.Mailbox - fields = ["id", "email", "role", "count_unread_messages", "count_messages"] + fields = [ + "id", + "email", + "role", + "count_unread_messages", + "count_messages", + "max_recipients_per_message", + ] def get_email(self, instance): """Return the email of the mailbox.""" @@ -280,6 +289,11 @@ def get_abilities(self, instance): """Get abilities for the instance.""" return super().get_abilities(instance) + @extend_schema_field(serializers.IntegerField()) + def get_max_recipients_per_message(self, instance): + """Return the maximum number of recipients per message for the mailbox.""" + return instance.get_max_recipients_per_message() + class MailboxLightSerializer(serializers.ModelSerializer): """Serializer for mailbox details in thread access.""" @@ -857,6 +871,10 @@ class MailDomainAdminSerializer(AbilitiesModelSerializer): """Serialize mail domains for admin view.""" expected_dns_records = serializers.SerializerMethodField(read_only=True) + custom_limits = serializers.SerializerMethodField( + read_only=True, + help_text="Limits applied to this mail domain (e.g. max recipients per message).", + ) def get_expected_dns_records(self, instance): """Return the expected DNS records for the mail domain, only in detail views.""" @@ -868,9 +886,33 @@ def get_expected_dns_records(self, instance): return None + @extend_schema_field( + { + "type": "object", + "properties": { + "max_recipients_per_message": { + "type": "integer", + "nullable": True, + "description": "Maximum number of recipients per message for this domain.", + } + }, + "nullable": True, + } + ) + def get_custom_limits(self, instance): + """Return custom_limits with proper structure.""" + return instance.custom_limits or None + class Meta: model = models.MailDomain - fields = ["id", "name", "created_at", "updated_at", "expected_dns_records"] + fields = [ + "id", + "name", + "created_at", + "updated_at", + "expected_dns_records", + "custom_limits", + ] read_only_fields = fields @extend_schema_field( @@ -937,7 +979,7 @@ def validate(self, attrs): class MailDomainAdminWriteSerializer(serializers.ModelSerializer): - """Serialize mail domains for creating / editing admin view.""" + """Serialize mail domains for creating admin view.""" class Meta: model = models.MailDomain @@ -953,6 +995,53 @@ class Meta: read_only_fields = ["id", "created_at", "updated_at"] +class CustomLimitsUpdateMixin: + """Mixin for validating custom_limits field.""" + + def validate_custom_limits(self, value): + """Validate that custom_limits does not exceed global maximum.""" + if value and isinstance(value, dict): + max_recipients = value.get("max_recipients_per_message") + if max_recipients is not None and isinstance(max_recipients, int): + if max_recipients <= 0: + raise serializers.ValidationError( + _("The limit must be greater than 0.") + ) + if max_recipients > settings.MAX_RECIPIENTS_PER_MESSAGE: + raise serializers.ValidationError( + _( + "The limit cannot exceed the global maximum of %(max)s recipients." + ) + % {"max": settings.MAX_RECIPIENTS_PER_MESSAGE} + ) + return value + + +class MailDomainAdminUpdateSerializer( + CustomLimitsUpdateMixin, serializers.ModelSerializer +): + """Serialize mail domains for updating custom_limits only.""" + + class Meta: + model = models.MailDomain + fields = [ + "id", + "custom_limits", + ] + read_only_fields = ["id"] + + +class MailboxSettingsUpdateSerializer( + CustomLimitsUpdateMixin, serializers.ModelSerializer +): + """Serialize mailbox settings (custom_limits) for update operations.""" + + class Meta: + model = models.Mailbox + fields = ["id", "custom_limits"] + read_only_fields = ["id"] + + class MailboxAccessNestedUserSerializer(serializers.ModelSerializer): """ Serialize MailboxAccess for nesting within MailboxAdminSerializer. @@ -982,6 +1071,27 @@ class MailboxAdminSerializer(serializers.ModelSerializer): alias_of = serializers.PrimaryKeyRelatedField( required=False, allow_null=True, queryset=models.Mailbox.objects.none() ) + custom_limits = serializers.SerializerMethodField( + read_only=True, + help_text="Limits applied to this mailbox (e.g. max recipients per message).", + ) + + @extend_schema_field( + { + "type": "object", + "properties": { + "max_recipients_per_message": { + "type": "integer", + "nullable": True, + "description": "Maximum number of recipients per message for this mailbox.", + } + }, + "nullable": True, + } + ) + def get_custom_limits(self, instance): + """Return custom_limits with proper structure.""" + return instance.custom_limits or None class Meta: model = models.Mailbox @@ -996,6 +1106,7 @@ class Meta: "updated_at", "can_reset_password", "contact", + "custom_limits", ] read_only_fields = [ "id", @@ -1006,6 +1117,7 @@ class Meta: "updated_at", "can_reset_password", "contact", + "custom_limits", # Read-only here, use /settings/ endpoint to update ] def __init__(self, *args, **kwargs): @@ -1152,6 +1264,9 @@ class MailboxAdminCreateSerializer(MailboxAdminSerializer): """ Serialize Mailbox details for create admin endpoint, including users with access and metadata. + + Note: custom_limits is excluded from the response as it cannot be set during creation. + It can only be modified via the dedicated /settings/ endpoint. """ one_time_password = serializers.SerializerMethodField( @@ -1165,7 +1280,12 @@ def get_one_time_password(self, instance) -> str | None: class Meta: model = models.Mailbox - fields = MailboxAdminSerializer.Meta.fields + ["one_time_password"] + # Exclude custom_limits from creation response - it can only be set via /settings/ + fields = [ + field + for field in MailboxAdminSerializer.Meta.fields + if field != "custom_limits" + ] + ["one_time_password"] read_only_fields = fields diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index 3cc938c23..68087e08c 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -3,7 +3,10 @@ from django.conf import settings import rest_framework as drf -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import ( + OpenApiResponse, + extend_schema, +) from rest_framework.permissions import AllowAny from core.ai.utils import is_ai_enabled, is_ai_summary_enabled, is_auto_labels_enabled @@ -82,7 +85,28 @@ class ConfigView(drf.views.APIView): }, "MAX_INCOMING_EMAIL_SIZE": { "type": "integer", - "description": "Maximum size in bytes for incoming email (including attachments and body)", + "description": ( + "Maximum size in bytes for incoming email " + "(including attachments and body)" + ), + "readOnly": True, + }, + "MAX_RECIPIENTS_PER_MESSAGE": { + "type": "integer", + "description": ( + "Maximum number of recipients per message " + "(to + cc + bcc) for the entire system. " + "Cannot be exceeded." + ), + "readOnly": True, + }, + "MAX_DEFAULT_RECIPIENTS_PER_MESSAGE": { + "type": "integer", + "description": ( + "Default maximum number of recipients per message " + "(to + cc + bcc) for a mailbox or maildomain " + "if no custom limit is set." + ), "readOnly": True, }, }, @@ -98,6 +122,8 @@ class ConfigView(drf.views.APIView): "MAX_OUTGOING_ATTACHMENT_SIZE", "MAX_OUTGOING_BODY_SIZE", "MAX_INCOMING_EMAIL_SIZE", + "MAX_RECIPIENTS_PER_MESSAGE", + "MAX_DEFAULT_RECIPIENTS_PER_MESSAGE", ], }, ) @@ -132,6 +158,12 @@ def get(self, request): ) dict_settings["MAX_OUTGOING_BODY_SIZE"] = settings.MAX_OUTGOING_BODY_SIZE dict_settings["MAX_INCOMING_EMAIL_SIZE"] = settings.MAX_INCOMING_EMAIL_SIZE + dict_settings["MAX_RECIPIENTS_PER_MESSAGE"] = ( + settings.MAX_RECIPIENTS_PER_MESSAGE + ) + dict_settings["MAX_DEFAULT_RECIPIENTS_PER_MESSAGE"] = ( + settings.MAX_DEFAULT_RECIPIENTS_PER_MESSAGE + ) # Drive service if base_url := settings.DRIVE_CONFIG.get("base_url"): diff --git a/src/backend/core/api/viewsets/mailbox.py b/src/backend/core/api/viewsets/mailbox.py index a9c5a6059..a30ca70e9 100644 --- a/src/backend/core/api/viewsets/mailbox.py +++ b/src/backend/core/api/viewsets/mailbox.py @@ -1,6 +1,6 @@ """API ViewSet for Mailbox model.""" -from django.db.models import OuterRef, Q, Subquery +from django.db.models import Exists, OuterRef, Q, Subquery from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema from rest_framework import mixins, viewsets @@ -24,7 +24,7 @@ class MailboxViewSet( def get_queryset(self): """Restrict results to the current user's mailboxes.""" user = self.request.user - # For regular users, annotate with their actual role + # For regular users, annotate with their actual role and domain admin status return ( models.Mailbox.objects.filter(accesses__user=user) .prefetch_related("accesses__user", "domain") @@ -33,7 +33,15 @@ def get_queryset(self): models.MailboxAccess.objects.filter( mailbox=OuterRef("pk"), user=user ).values("role")[:1] - ) + ), + # Annotate domain admin status to avoid N+1 queries in get_abilities() + is_domain_admin=Exists( + models.MailDomainAccess.objects.filter( + user=user, + maildomain=OuterRef("domain"), + role=models.MailDomainAccessRoleChoices.ADMIN, + ) + ), ) .order_by("-created_at") ) diff --git a/src/backend/core/api/viewsets/maildomain.py b/src/backend/core/api/viewsets/maildomain.py index 3f57ecdbb..90fbf8c6c 100644 --- a/src/backend/core/api/viewsets/maildomain.py +++ b/src/backend/core/api/viewsets/maildomain.py @@ -4,7 +4,7 @@ from django.conf import settings from django.db import transaction -from django.db.models import F +from django.db.models import Exists, F, OuterRef, Subquery from django.shortcuts import get_object_or_404 from drf_spectacular.utils import ( @@ -38,6 +38,7 @@ class AdminMailDomainViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, + mixins.UpdateModelMixin, viewsets.GenericViewSet, ): """ @@ -55,12 +56,16 @@ class AdminMailDomainViewSet( def get_permissions(self): if self.action == "create": return [core_permissions.IsSuperUser()] + if self.action in ["update", "partial_update"]: + return [core_permissions.CanManageSettings()] return super().get_permissions() def get_serializer_class(self): """Select serializer based on action.""" if self.action == "create": return core_serializers.MailDomainAdminWriteSerializer + if self.action in ["update", "partial_update"]: + return core_serializers.MailDomainAdminUpdateSerializer return super().get_serializer_class() def get_queryset(self): @@ -159,9 +164,40 @@ class AdminMailDomainMailboxViewSet( ] serializer_class = core_serializers.MailboxAdminSerializer + def get_serializer_class(self): + """Select serializer based on action.""" + if self.action == "update_settings": + return core_serializers.MailboxSettingsUpdateSerializer + return super().get_serializer_class() + + def get_permissions(self): + """Override permissions for specific actions.""" + if self.action == "update_settings": + # Domain admin + ability to manage settings + return [permission() for permission in self.permission_classes] + [ + core_permissions.CanManageSettings() + ] + return super().get_permissions() + def get_queryset(self): maildomain_pk = self.kwargs.get("maildomain_pk") - return models.Mailbox.objects.filter(domain_id=maildomain_pk) + user = self.request.user + return models.Mailbox.objects.filter(domain_id=maildomain_pk).annotate( + # Annotate user role for get_abilities() optimization + user_role=Subquery( + models.MailboxAccess.objects.filter( + mailbox=OuterRef("pk"), user=user + ).values("role")[:1] + ), + # Annotate domain admin status to avoid N+1 queries in get_abilities() + is_domain_admin=Exists( + models.MailDomainAccess.objects.filter( + user=user, + maildomain=OuterRef("domain"), + role=models.MailDomainAccessRoleChoices.ADMIN, + ) + ), + ) @extend_schema( description="Create new mailbox in a specific maildomain.", @@ -238,6 +274,7 @@ def create(self, request, *args, **kwargs): required=False, allow_blank=True ), "custom_attributes": drf_serializers.JSONField(required=False), + "custom_limits": drf_serializers.JSONField(required=False), }, ), }, @@ -332,6 +369,38 @@ def reset_password(self, request, *args, **kwargs): {"one_time_password": mailbox_password}, status=status.HTTP_200_OK ) + @extend_schema( + operation_id="maildomains_mailboxes_settings_update", + description="Update mailbox settings (custom_limits).", + request=core_serializers.MailboxSettingsUpdateSerializer, + responses={ + 200: OpenApiResponse( + response=core_serializers.MailboxSettingsUpdateSerializer, + description="Mailbox settings updated successfully.", + ), + 400: OpenApiResponse( + description="Invalid settings data.", + ), + 403: OpenApiResponse( + description="User does not have permission to manage settings.", + ), + }, + ) + @action(detail=True, methods=["patch"], url_path="settings") + def update_settings(self, request, maildomain_pk=None, pk=None): + """ + Update mailbox settings (custom_limits). + + Only domain administrators can manage mailbox settings. + This endpoint is separate from the general mailbox update endpoint + to enforce proper permission checks at the viewset level. + """ + mailbox = self.get_object() + serializer = self.get_serializer(mailbox, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + class AdminMailDomainMessageTemplateViewSet( mixins.CreateModelMixin, diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index b159746b1..cc08861c9 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -98,6 +98,7 @@ class MailDomainAbilities(models.TextChoices): CAN_MANAGE_ACCESSES = "manage_accesses", "Can manage accesses" CAN_MANAGE_MAILBOXES = "manage_mailboxes", "Can manage mailboxes" + CAN_MANAGE_SETTINGS = "manage_settings", "Can manage settings" class MailboxAbilities(models.TextChoices): @@ -112,6 +113,7 @@ class MailboxAbilities(models.TextChoices): "Can manage mailbox message templates", ) CAN_IMPORT_MESSAGES = "import_messages", "Can import messages" + CAN_MANAGE_SETTINGS = "manage_settings", "Can manage settings" class MessageTemplateTypeChoices(models.IntegerChoices): diff --git a/src/backend/core/mda/draft.py b/src/backend/core/mda/draft.py index 2df538f3b..cf1220d14 100644 --- a/src/backend/core/mda/draft.py +++ b/src/backend/core/mda/draft.py @@ -98,6 +98,19 @@ def create_draft( drf.exceptions.PermissionDenied: If access denied to parent thread """ + # Normalize recipient lists + to_emails = to_emails or [] + cc_emails = cc_emails or [] + bcc_emails = bcc_emails or [] + + # Enforce per-message recipient limit (to + cc + bcc) + max_recipients = mailbox.get_max_recipients_per_message() + total_recipients = len(to_emails) + len(cc_emails) + len(bcc_emails) + if total_recipients > max_recipients: + raise drf.exceptions.ValidationError( + f"Too many recipients: {total_recipients} (maximum is {max_recipients})." + ) + # Get or create sender contact mailbox_email = f"{mailbox.local_part}@{mailbox.domain.name}" sender_contact, _created = models.Contact.objects.get_or_create( @@ -168,9 +181,9 @@ def create_draft( # Update draft details with recipients and attachments update_data = { - "to": to_emails or [], - "cc": cc_emails or [], - "bcc": bcc_emails or [], + "to": to_emails, + "cc": cc_emails, + "bcc": bcc_emails, "attachments": attachments or [], } @@ -236,6 +249,30 @@ def update_draft( message.thread.subject = update_data["subject"] thread_updated_fields.append("subject") + # Enforce per-message recipient limit (to + cc + bcc) + max_recipients = mailbox.get_max_recipients_per_message() + # Current counts per type + current_counts = {} + for kind, kind_choice in [ + ("to", enums.MessageRecipientTypeChoices.TO), + ("cc", enums.MessageRecipientTypeChoices.CC), + ("bcc", enums.MessageRecipientTypeChoices.BCC), + ]: + current_counts[kind] = message.recipients.filter(type=kind_choice).count() + + # Compute total after applying updates (partial updates allowed) + total_after_update = 0 + for kind in ["to", "cc", "bcc"]: + if kind in update_data: + total_after_update += len(update_data.get(kind) or []) + else: + total_after_update += current_counts[kind] + + if total_after_update > max_recipients: + raise drf.exceptions.ValidationError( + f"Too many recipients: {total_after_update} (maximum is {max_recipients})." + ) + # Update recipients if provided recipient_type_mapping = { "to": enums.MessageRecipientTypeChoices.TO, diff --git a/src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py b/src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py new file mode 100644 index 000000000..19a5e4013 --- /dev/null +++ b/src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.13 on 2025-11-23 21:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_alter_messagetemplate_type'), + ] + + operations = [ + migrations.AddField( + model_name='mailbox', + name='custom_limits', + field=models.JSONField(blank=True, default=dict, help_text='Limits applied to this mailbox (e.g. max recipients per message).', verbose_name='Custom limits'), + ), + migrations.AddField( + model_name='maildomain', + name='custom_limits', + field=models.JSONField(blank=True, default=dict, help_text='Limits applied to this mail domain (e.g. max recipients per message).', verbose_name='Custom limits'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 0941df5de..1848a11cc 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -279,6 +279,14 @@ class MailDomain(BaseModel): "Metadata to sync to the maildomain group in the identity provider." ), ) + custom_limits = models.JSONField( + _("Custom limits"), + default=dict, + blank=True, + help_text=_( + "Limits applied to this mail domain (e.g. max recipients per message)." + ), + ) class Meta: db_table = "messages_maildomain" @@ -294,7 +302,8 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def clean(self): - """Validate custom attributes.""" + """Validate custom attributes and limits.""" + # Custom attributes schema try: jsonschema.validate( self.custom_attributes, settings.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN @@ -375,6 +384,8 @@ def get_abilities(self, user): CRUDAbilities.CAN_DELETE: is_admin, MailDomainAbilities.CAN_MANAGE_ACCESSES: is_admin, MailDomainAbilities.CAN_MANAGE_MAILBOXES: is_admin, + # For now, only superusers can manage settings for maildomains (custom_limits) + MailDomainAbilities.CAN_MANAGE_SETTINGS: user.is_superuser, } def generate_dkim_key( @@ -497,6 +508,14 @@ class Mailbox(BaseModel): alias_of = models.ForeignKey( "self", on_delete=models.SET_NULL, null=True, blank=True ) + custom_limits = models.JSONField( + _("Custom limits"), + default=dict, + blank=True, + help_text=_( + "Limits applied to this mailbox (e.g. max recipients per message)." + ), + ) class Meta: db_table = "messages_mailbox" @@ -508,6 +527,30 @@ class Meta: def __str__(self): return f"{self.local_part}@{self.domain.name}" + def save(self, *args, **kwargs): + """Enforce validation before saving.""" + self.full_clean() + super().save(*args, **kwargs) + + def clean(self): + """Validate custom limits.""" + # Validate custom_limits structure + if self.custom_limits: + if not isinstance(self.custom_limits, dict): + raise ValidationError( + {"custom_limits": "custom_limits must be a dictionary"} + ) + if "max_recipients_per_message" in self.custom_limits: + value = self.custom_limits["max_recipients_per_message"] + if value is not None and (not isinstance(value, int) or value < 1): + raise ValidationError( + { + "custom_limits": "max_recipients_per_message must be a positive integer" + } + ) + + super().clean() + @property def can_reset_password(self) -> bool: """Return True if the mailbox user's password can be reset.""" @@ -577,12 +620,24 @@ def get_abilities(self, user): """ Compute and return abilities for a given user on the mailbox. """ - role = None + role = 0 + is_domain_admin = False if user.is_authenticated: + # Use the annotated is_domain_admin field if available (optimized N+1) + try: + is_domain_admin = self.is_domain_admin + except AttributeError: + # Fallback to query if not pre-calculated + is_domain_admin = MailDomainAccess.objects.filter( + user=user, + maildomain=self.domain, + role=MailDomainAccessRoleChoices.ADMIN, + ).exists() + # Use the annotated user_role field try: - role = self.user_role + role = self.user_role or 0 # Fallback to query if not pre-calculated (should not happen with optimized ViewSet) except AttributeError: if ( @@ -598,22 +653,7 @@ def get_abilities(self, user): try: role = self.accesses.filter(user=user).values("role")[0]["role"] except (MailboxAccess.DoesNotExist, IndexError): - role = None - - if role is None: - return { - "get": False, - "patch": False, - "put": False, - "post": False, - "delete": False, - "manage_accesses": False, - "view_messages": False, - "send_messages": False, - "manage_labels": False, - "manage_message_templates": False, - "import_messages": False, - } + role = 0 is_admin = role == MailboxRoleChoices.ADMIN can_modify = role >= MailboxRoleChoices.EDITOR @@ -628,6 +668,7 @@ def get_abilities(self, user): CRUDAbilities.CAN_UPDATE: can_modify, CRUDAbilities.CAN_DELETE: can_delete, MailboxAbilities.CAN_MANAGE_ACCESSES: is_admin, + MailboxAbilities.CAN_MANAGE_SETTINGS: is_domain_admin, MailboxAbilities.CAN_VIEW_MESSAGES: has_access, MailboxAbilities.CAN_SEND_MESSAGES: can_send, MailboxAbilities.CAN_MANAGE_LABELS: can_modify, @@ -695,6 +736,37 @@ def get_validated_signature(self, signature_id: str): return signature + def get_max_recipients_per_message(self): + """Return the effective max recipients per message for a mailbox. + + Priority: + 1. Mailbox custom_limits.max_recipients_per_message + 2. MailDomain custom_limits.max_recipients_per_message + 3. Global setting MAX_DEFAULT_RECIPIENTS_PER_MESSAGE + 4. Global setting MAX_RECIPIENTS_PER_MESSAGE (cannot be exceeded) + """ + # Mailbox-level override + if hasattr(self, "custom_limits") and self.custom_limits: + value = self.custom_limits.get("max_recipients_per_message") + if isinstance(value, int) and value > 0: + return min(value, settings.MAX_RECIPIENTS_PER_MESSAGE) + + # MailDomain-level override + if ( + self.domain + and hasattr(self.domain, "custom_limits") + and self.domain.custom_limits + ): + value = self.domain.custom_limits.get("max_recipients_per_message") + if isinstance(value, int) and value > 0: + return min(value, settings.MAX_RECIPIENTS_PER_MESSAGE) + + # Global fallback - ensure it never exceeds the global maximum + return min( + settings.MAX_DEFAULT_RECIPIENTS_PER_MESSAGE, + settings.MAX_RECIPIENTS_PER_MESSAGE, + ) + class MailboxAccess(BaseModel): """Mailbox access model to store mailbox access information.""" diff --git a/src/backend/core/tests/api/test_admin_maildomains_list.py b/src/backend/core/tests/api/test_admin_maildomains_list.py index 5d76af5f7..f3bbdcb4a 100644 --- a/src/backend/core/tests/api/test_admin_maildomains_list.py +++ b/src/backend/core/tests/api/test_admin_maildomains_list.py @@ -357,7 +357,7 @@ class TestMailDomainAbilitiesAPI: def test_maildomain_abilities_in_response( self, api_client, domain_admin_user, domain_admin_access1 ): - """Test that abilities are included in mail domain API response.""" + """Test that abilities are included in mail domain API response for domain admin.""" api_client.force_authenticate(user=domain_admin_user) url = reverse( "admin-maildomains-detail", @@ -375,11 +375,13 @@ def test_maildomain_abilities_in_response( assert abilities["delete"] is True assert abilities["manage_accesses"] is True assert abilities["manage_mailboxes"] is True + # Only superusers can manage settings for maildomains + assert abilities["manage_settings"] is False def test_maildomain_list_with_abilities( self, api_client, domain_admin_user, domain_admin_access1 ): - """Test that mail domain list includes abilities for each domain.""" + """Test that mail domain list includes abilities for each domain (for domain admin).""" api_client.force_authenticate(user=domain_admin_user) url = reverse("admin-maildomains-list") response = api_client.get(url) @@ -397,6 +399,8 @@ def test_maildomain_list_with_abilities( assert abilities["delete"] is True assert abilities["manage_accesses"] is True assert abilities["manage_mailboxes"] is True + # Only superusers can manage settings for maildomains + assert abilities["manage_settings"] is False def test_maildomain_detail_permissions_user_without_access( self, api_client, other_user, mail_domain1 diff --git a/src/backend/core/tests/api/test_admin_maildomains_update.py b/src/backend/core/tests/api/test_admin_maildomains_update.py new file mode 100644 index 000000000..c91502e47 --- /dev/null +++ b/src/backend/core/tests/api/test_admin_maildomains_update.py @@ -0,0 +1,362 @@ +"""Test API admin maildomains update (custom_limits).""" + +from django.test import override_settings + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import enums, factories + + +@pytest.fixture(name="authenticated_admin") +def fixture_authenticated_admin(): + """Create an authenticated admin user.""" + return factories.UserFactory(full_name="Admin User", email="admin@example.com") + + +@pytest.fixture(name="maildomain") +def fixture_maildomain(): + """Create a mail domain.""" + return factories.MailDomainFactory(name="test.example.com") + + +@pytest.mark.django_db +class TestAdminMaildomainUpdateCustomLimits: + """Test API for updating maildomain custom_limits.""" + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_update_custom_limits_as_admin(self, maildomain, authenticated_admin): + """Test that a maildomain admin CANNOT update custom_limits (only superusers can).""" + # Give admin access to the domain + factories.MailDomainAccessFactory( + maildomain=maildomain, + user=authenticated_admin, + role=enums.MailDomainAccessRoleChoices.ADMIN, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_admin) + + # Try to update custom_limits (should be forbidden) + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/", + {"custom_limits": {"max_recipients_per_message": 50}}, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Verify the limit was NOT updated + maildomain.refresh_from_db() + assert maildomain.custom_limits.get("max_recipients_per_message") is None + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_update_custom_limits_as_superuser(self, maildomain): + """Test that a superuser can update custom_limits.""" + superuser = factories.UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + # Update custom_limits + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/", + {"custom_limits": {"max_recipients_per_message": 100}}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify the update + maildomain.refresh_from_db() + assert maildomain.custom_limits == {"max_recipients_per_message": 100} + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_update_custom_limits_clear_value(self, maildomain): + """Test that custom_limits can be cleared by superuser.""" + # Set initial value + maildomain.custom_limits = {"max_recipients_per_message": 50} + maildomain.save() + + # Use a superuser + superuser = factories.UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + # Clear custom_limits by setting to null + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/", + {"custom_limits": {"max_recipients_per_message": None}}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify the update + maildomain.refresh_from_db() + assert maildomain.custom_limits.get("max_recipients_per_message") is None + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_update_custom_limits_unauthorized(self, maildomain): + """Test that unauthenticated users cannot update custom_limits.""" + client = APIClient() + + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/", + {"custom_limits": {"max_recipients_per_message": 50}}, + format="json", + ) + + # Returns 404 because unauthenticated users have empty queryset + assert response.status_code == status.HTTP_404_NOT_FOUND + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_update_custom_limits_forbidden_non_admin(self, maildomain): + """Test that non-admin users cannot update custom_limits.""" + regular_user = factories.UserFactory() + + client = APIClient() + client.force_authenticate(user=regular_user) + + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/", + {"custom_limits": {"max_recipients_per_message": 50}}, + format="json", + ) + + # Returns 404 because non-admin users don't have access to this maildomain in queryset + # This is the standard DRF behavior and is more secure (doesn't reveal object existence) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_cannot_update_other_fields(self, maildomain): + """Test that only custom_limits can be updated by superuser, not other fields like name.""" + original_name = maildomain.name + + # Use a superuser + superuser = factories.UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + # Try to update name (should be ignored or rejected) + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/", + { + "name": "hacked.example.com", + "custom_limits": {"max_recipients_per_message": 50}, + }, + format="json", + ) + + # The request should succeed but name should not change + assert response.status_code == status.HTTP_200_OK + + maildomain.refresh_from_db() + assert maildomain.name == original_name # Name unchanged + assert maildomain.custom_limits == { + "max_recipients_per_message": 50 + } # Limits updated + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_cannot_exceed_global_max_recipients(self, maildomain): + """Test that custom_limits cannot exceed MAX_RECIPIENTS_PER_MESSAGE even for superuser.""" + # Use a superuser + superuser = factories.UserFactory(is_superuser=True) + + client = APIClient() + client.force_authenticate(user=superuser) + + # Try to set limit higher than global max (100) + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/", + {"custom_limits": {"max_recipients_per_message": 150}}, + format="json", + ) + + # Should be rejected + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_limits" in response.json() + assert "100" in str(response.json()["custom_limits"]) + + # Verify the limit was not updated + maildomain.refresh_from_db() + assert maildomain.custom_limits.get("max_recipients_per_message") != 150 + + +@pytest.mark.django_db +class TestAdminMailboxUpdateCustomLimits: + """Test API for updating mailbox custom_limits.""" + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_update_mailbox_custom_limits_as_admin( + self, maildomain, authenticated_admin + ): + """Test that a maildomain admin can update mailbox custom_limits via settings endpoint.""" + mailbox = factories.MailboxFactory(domain=maildomain) + + # Give admin access to the domain + factories.MailDomainAccessFactory( + maildomain=maildomain, + user=authenticated_admin, + role=enums.MailDomainAccessRoleChoices.ADMIN, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_admin) + + # Update mailbox custom_limits via the dedicated settings endpoint + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/mailboxes/{mailbox.id}/settings/", + {"custom_limits": {"max_recipients_per_message": 25}}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify the update + mailbox.refresh_from_db() + assert mailbox.custom_limits == {"max_recipients_per_message": 25} + + def test_mailbox_limit_takes_priority_over_domain( + self, maildomain, authenticated_admin + ): + """Test that mailbox custom_limits takes priority over domain limits.""" + # Set domain limit + maildomain.custom_limits = {"max_recipients_per_message": 100} + maildomain.save() + + # Create mailbox with its own limit + mailbox = factories.MailboxFactory( + domain=maildomain, custom_limits={"max_recipients_per_message": 10} + ) + + # Give admin access + factories.MailDomainAccessFactory( + maildomain=maildomain, + user=authenticated_admin, + role=enums.MailDomainAccessRoleChoices.ADMIN, + ) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_admin, + ) + + # The effective limit should be 10 (mailbox), not 100 (domain) + assert mailbox.get_max_recipients_per_message() == 10 + + @override_settings( + MAX_RECIPIENTS_PER_MESSAGE=200, MAX_DEFAULT_RECIPIENTS_PER_MESSAGE=100 + ) + def test_domain_limit_used_when_mailbox_has_none(self): + """Test that domain limit is used when mailbox has no custom limit.""" + + # Create a mailbox with no custom limit + mailbox = factories.MailboxFactory() + assert mailbox.get_max_recipients_per_message() == 100 + + # Set domain limit + maildomain = mailbox.domain + maildomain.custom_limits = {"max_recipients_per_message": 75} + maildomain.save() + + assert mailbox.custom_limits == {} + assert mailbox.domain.custom_limits == {"max_recipients_per_message": 75} + + # The effective limit should be 75 (from domain) + assert mailbox.get_max_recipients_per_message() == 75 + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_mailbox_cannot_exceed_global_max_recipients( + self, maildomain, authenticated_admin + ): + """Test that mailbox custom_limits cannot exceed MAX_RECIPIENTS_PER_MESSAGE.""" + mailbox = factories.MailboxFactory(domain=maildomain) + + # Give admin access + factories.MailDomainAccessFactory( + maildomain=maildomain, + user=authenticated_admin, + role=enums.MailDomainAccessRoleChoices.ADMIN, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_admin) + + # Try to set mailbox limit higher than global max (100) via settings endpoint + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/mailboxes/{mailbox.id}/settings/", + {"custom_limits": {"max_recipients_per_message": 150}}, + format="json", + ) + + # Should be rejected + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "custom_limits" in response.json() + assert "100" in str(response.json()["custom_limits"]) + + # Verify the limit was not updated + mailbox.refresh_from_db() + assert mailbox.custom_limits.get("max_recipients_per_message") != 150 + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=100) + def test_mailbox_settings_forbidden_for_mailbox_admin_only( + self, maildomain, authenticated_admin + ): + """Test that mailbox admin (without domain admin) cannot update settings.""" + mailbox = factories.MailboxFactory(domain=maildomain) + + # Give mailbox admin access (but NOT domain admin) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_admin, + role=enums.MailboxRoleChoices.ADMIN, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_admin) + + # Try to update settings (should be forbidden) + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/mailboxes/{mailbox.id}/settings/", + {"custom_limits": {"max_recipients_per_message": 25}}, + format="json", + ) + + # Should be forbidden (need to be domain admin) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_mailbox_name_update_without_manage_settings( + self, maildomain, authenticated_admin + ): + """Test that domain admin can update mailbox name without CAN_MANAGE_SETTINGS.""" + mailbox = factories.MailboxFactory(domain=maildomain, is_identity=False) + contact = factories.ContactFactory( + mailbox=mailbox, email=str(mailbox), name="Old Name" + ) + mailbox.contact = contact + mailbox.save() + + # Give domain admin access (has CAN_MANAGE_SETTINGS for mailbox) + factories.MailDomainAccessFactory( + maildomain=maildomain, + user=authenticated_admin, + role=enums.MailDomainAccessRoleChoices.ADMIN, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_admin) + + # Update mailbox name (should work without needing settings permission) + response = client.patch( + f"/api/v1.0/maildomains/{maildomain.id}/mailboxes/{mailbox.id}/", + {"metadata": {"name": "New Team Name"}}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + + # Verify name was updated + mailbox.refresh_from_db() + assert mailbox.contact.name == "New Team Name" diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index ea917088d..2a921b36d 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -27,6 +27,8 @@ MAX_OUTGOING_ATTACHMENT_SIZE=20971520, # 20MB MAX_OUTGOING_BODY_SIZE=5242880, # 5MB MAX_INCOMING_EMAIL_SIZE=10485760, # 10MB + MAX_RECIPIENTS_PER_MESSAGE=200, + MAX_DEFAULT_RECIPIENTS_PER_MESSAGE=200, ) @pytest.mark.parametrize("is_authenticated", [False, True]) def test_api_config(is_authenticated): @@ -51,6 +53,8 @@ def test_api_config(is_authenticated): "MAX_INCOMING_EMAIL_SIZE": 10485760, "MAX_OUTGOING_ATTACHMENT_SIZE": 20971520, "MAX_OUTGOING_BODY_SIZE": 5242880, + "MAX_RECIPIENTS_PER_MESSAGE": 200, + "MAX_DEFAULT_RECIPIENTS_PER_MESSAGE": 200, } diff --git a/src/backend/core/tests/api/test_mailboxes.py b/src/backend/core/tests/api/test_mailboxes.py index 526db5528..d0ef9ca99 100644 --- a/src/backend/core/tests/api/test_mailboxes.py +++ b/src/backend/core/tests/api/test_mailboxes.py @@ -367,7 +367,7 @@ class TestMailboxAbilitiesAPI: @override_settings(FEATURE_MESSAGE_TEMPLATES=True, FEATURE_IMPORT_MESSAGES=True) def test_mailbox_abilities_in_response(self, api_client, user, mailbox): - """Test that abilities are included in mailbox API response.""" + """Test that abilities are included in mailbox API response (mailbox admin only).""" models.MailboxAccess.objects.create( mailbox=mailbox, user=user, @@ -387,6 +387,8 @@ def test_mailbox_abilities_in_response(self, api_client, user, mailbox): assert abilities["post"] is True assert abilities["delete"] is True assert abilities["manage_accesses"] is True + # Only domain admins can manage settings for mailboxes + assert abilities["manage_settings"] is False assert abilities["view_messages"] is True assert abilities["send_messages"] is True assert abilities["manage_labels"] is True @@ -417,6 +419,7 @@ def test_mailbox_list_with_abilities(self, api_client, user, mailbox): assert abilities["post"] is True assert abilities["delete"] is False assert abilities["manage_accesses"] is False + assert abilities["manage_settings"] is False assert abilities["view_messages"] is True assert abilities["send_messages"] is False assert abilities["manage_labels"] is True @@ -464,6 +467,7 @@ def test_mailbox_viewer_abilities(self, api_client, user, mailbox): assert abilities["post"] is False assert abilities["delete"] is False assert abilities["manage_accesses"] is False + assert abilities["manage_settings"] is False assert abilities["view_messages"] is True assert abilities["send_messages"] is False assert abilities["manage_labels"] is False diff --git a/src/backend/core/tests/api/test_messages_create.py b/src/backend/core/tests/api/test_messages_create.py index 3b7de4cef..bcde7fec2 100644 --- a/src/backend/core/tests/api/test_messages_create.py +++ b/src/backend/core/tests/api/test_messages_create.py @@ -6,6 +6,7 @@ import uuid from unittest.mock import patch +from django.test import override_settings from django.urls import reverse from django.utils import timezone @@ -806,6 +807,276 @@ def test_draft_message_with_very_long_subject(self, mailbox, authenticated_user) # Should fail due to max_length constraint assert draft_response.status_code == status.HTTP_400_BAD_REQUEST + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=2) + def test_draft_message_recipient_limit_exceeded(self, mailbox, authenticated_user): + """Test that creating a draft with too many recipients is rejected.""" + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_user) + + # 3 recipients while limit is 2 + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Too many recipients", + "draftBody": "Test content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + "bcc": ["c@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=10) + def test_draft_message_recipient_limit_mailbox_override(self, authenticated_user): + """Test that mailbox custom_limits overrides global MAX_RECIPIENTS_PER_MESSAGE.""" + # Create mailbox with custom limit of 2 + mailbox = factories.MailboxFactory( + custom_limits={"max_recipients_per_message": 2} + ) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_user) + + # 3 recipients - should fail because mailbox limit is 2 (not global 10) + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Too many recipients", + "draftBody": "Test content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + "bcc": ["c@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # 2 recipients - should succeed + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Within limit", + "draftBody": "Test content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=10) + def test_draft_message_recipient_limit_maildomain_override( + self, authenticated_user + ): + """Test that maildomain custom_limits overrides global MAX_RECIPIENTS_PER_MESSAGE.""" + # Create domain with custom limit of 3 + domain = factories.MailDomainFactory( + custom_limits={"max_recipients_per_message": 3} + ) + mailbox = factories.MailboxFactory(domain=domain) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_user) + + # 4 recipients - should fail because domain limit is 3 (not global 10) + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Too many recipients", + "draftBody": "Test content", + "to": ["a@example.com", "b@example.com"], + "cc": ["c@example.com"], + "bcc": ["d@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # 3 recipients - should succeed + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Within limit", + "draftBody": "Test content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + "bcc": ["c@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=10) + def test_draft_message_recipient_limit_mailbox_overrides_maildomain( + self, authenticated_user + ): + """Test that mailbox custom_limits takes priority over maildomain custom_limits.""" + # Create domain with limit of 5 + domain = factories.MailDomainFactory( + custom_limits={"max_recipients_per_message": 5} + ) + # Create mailbox with limit of 2 (more restrictive) + mailbox = factories.MailboxFactory( + domain=domain, custom_limits={"max_recipients_per_message": 2} + ) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_user) + + # 3 recipients - should fail because mailbox limit is 2 (not domain's 5) + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Too many recipients", + "draftBody": "Test content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + "bcc": ["c@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # 2 recipients - should succeed + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Within limit", + "draftBody": "Test content", + "to": ["a@example.com", "b@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + + @override_settings(MAX_RECIPIENTS_PER_MESSAGE=2) + def test_update_draft_message_recipient_limit_exceeded( + self, authenticated_user, draft_detail_url + ): + """Test that updating a draft with too many recipients is rejected.""" + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_user) + + # Create draft with 1 recipient (within limit) + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Initial draft", + "draftBody": "Test content", + "to": ["a@example.com"], + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + draft_id = response.data["id"] + + # Try to update with 3 recipients (exceeds limit of 2) + response = client.put( + draft_detail_url(draft_id), + { + "senderId": mailbox.id, + "subject": "Updated draft", + "draftBody": "Updated content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + "bcc": ["c@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings( + MAX_RECIPIENTS_PER_MESSAGE=10, MAX_DEFAULT_RECIPIENTS_PER_MESSAGE=2 + ) + def test_draft_message_recipient_limit_default_setting( + self, mailbox, authenticated_user + ): + """Test that MAX_DEFAULT_RECIPIENTS_PER_MESSAGE is enforced when no custom limits exist.""" + factories.MailboxAccessFactory( + mailbox=mailbox, + user=authenticated_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=authenticated_user) + + # Create draft with 3 recipients (exceeds default limit of 2) + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Too many recipients", + "draftBody": "Test content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + "bcc": ["c@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Create draft with 2 recipients (within default limit of 2) + response = client.post( + reverse("draft-message"), + { + "senderId": mailbox.id, + "subject": "Within limit", + "draftBody": "Test content", + "to": ["a@example.com"], + "cc": ["b@example.com"], + }, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + def test_send_nonexistent_message(self, mailbox, authenticated_user, send_url): """Test sending a message that does not exist.""" factories.MailboxAccessFactory( diff --git a/src/backend/core/tests/models/test_mailbox.py b/src/backend/core/tests/models/test_mailbox.py index 9209baf06..60959648c 100644 --- a/src/backend/core/tests/models/test_mailbox.py +++ b/src/backend/core/tests/models/test_mailbox.py @@ -113,6 +113,7 @@ def test_mailbox_get_abilities_no_access(self, user, mailbox): assert abilities["post"] is False assert abilities["delete"] is False assert abilities["manage_accesses"] is False + assert abilities["manage_settings"] is False assert abilities["view_messages"] is False assert abilities["send_messages"] is False assert abilities["manage_labels"] is False @@ -135,6 +136,7 @@ def test_mailbox_get_abilities_viewer(self, user, mailbox): assert abilities["post"] is False assert abilities["delete"] is False assert abilities["manage_accesses"] is False + assert abilities["manage_settings"] is False assert abilities["view_messages"] is True assert abilities["send_messages"] is False assert abilities["manage_labels"] is False @@ -157,6 +159,7 @@ def test_mailbox_get_abilities_editor(self, user, mailbox): assert abilities["post"] is True assert abilities["delete"] is False assert abilities["manage_accesses"] is False + assert abilities["manage_settings"] is False assert abilities["view_messages"] is True assert abilities["send_messages"] is False assert abilities["manage_labels"] is True @@ -165,7 +168,7 @@ def test_mailbox_get_abilities_editor(self, user, mailbox): @override_settings(FEATURE_MESSAGE_TEMPLATES=True, FEATURE_IMPORT_MESSAGES=True) def test_mailbox_get_abilities_admin(self, user, mailbox): - """Test Mailbox.get_abilities when user has admin access.""" + """Test Mailbox.get_abilities when user has mailbox admin access (but not domain admin).""" models.MailboxAccess.objects.create( mailbox=mailbox, user=user, @@ -180,6 +183,8 @@ def test_mailbox_get_abilities_admin(self, user, mailbox): assert abilities["post"] is True assert abilities["delete"] is True assert abilities["manage_accesses"] is True + # Only domain admins can manage settings for mailboxes + assert abilities["manage_settings"] is False assert abilities["view_messages"] is True assert abilities["send_messages"] is True assert abilities["manage_labels"] is True @@ -202,6 +207,7 @@ def test_mailbox_get_abilities_sender(self, user, mailbox): assert abilities["post"] is True assert abilities["delete"] is False assert abilities["manage_accesses"] is False + assert abilities["manage_settings"] is False assert abilities["view_messages"] is True assert abilities["send_messages"] is True assert abilities["manage_labels"] is True diff --git a/src/backend/core/tests/models/test_maildomain.py b/src/backend/core/tests/models/test_maildomain.py index 6016d3be6..98eb71859 100644 --- a/src/backend/core/tests/models/test_maildomain.py +++ b/src/backend/core/tests/models/test_maildomain.py @@ -138,6 +138,7 @@ def test_models_maildomain_get_abilities_no_access(self, user, maildomain): assert abilities["delete"] is False assert abilities["manage_accesses"] is False assert abilities["manage_mailboxes"] is False + assert abilities["manage_settings"] is False def test_models_maildomain_get_abilities_admin(self, user, maildomain): """Test MailDomain.get_abilities when user has admin access.""" @@ -156,3 +157,21 @@ def test_models_maildomain_get_abilities_admin(self, user, maildomain): assert abilities["delete"] is True assert abilities["manage_accesses"] is True assert abilities["manage_mailboxes"] is True + # Only superusers can manage settings for maildomains + assert abilities["manage_settings"] is False + + def test_models_maildomain_get_abilities_superuser(self, user, maildomain): + """Test MailDomain.get_abilities when user is superuser.""" + user.is_superuser = True + user.save() + + abilities = maildomain.get_abilities(user) + + assert abilities["get"] is False + assert abilities["patch"] is True + assert abilities["put"] is True + assert abilities["post"] is True + assert abilities["delete"] is True + assert abilities["manage_accesses"] is True + assert abilities["manage_mailboxes"] is True + assert abilities["manage_settings"] is True diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index e2b2ec50e..3a12e68d2 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -249,6 +249,19 @@ class Base(Configuration): "may", environ_name="MTA_OUT_SMTP_TLS_SECURITY_LEVEL", environ_prefix=None ) + # Outgoing email limits + # Maximum total recipients (to + cc + bcc) allowed per message for the entire system + # This is maximum allowed recipients per message for the entire system, + # mailboxes and maildomains can only have a lower limit than this value. + MAX_RECIPIENTS_PER_MESSAGE = values.PositiveIntegerValue( + 200, environ_name="MAX_RECIPIENTS_PER_MESSAGE", environ_prefix=None + ) + # Default maximum allowed recipients per message for a mailbox or maildomain + # if no custom limit is set. + MAX_DEFAULT_RECIPIENTS_PER_MESSAGE = values.PositiveIntegerValue( + 200, environ_name="MAX_DEFAULT_RECIPIENTS_PER_MESSAGE", environ_prefix=None + ) + # Test domain settings MESSAGES_TESTDOMAIN = values.Value( None, environ_name="MESSAGES_TESTDOMAIN", environ_prefix=None diff --git a/src/e2e/src/__tests__/message-max-recipients-admin.spec.ts b/src/e2e/src/__tests__/message-max-recipients-admin.spec.ts new file mode 100644 index 000000000..e06930acb --- /dev/null +++ b/src/e2e/src/__tests__/message-max-recipients-admin.spec.ts @@ -0,0 +1,101 @@ +import test, { expect } from "@playwright/test"; +import { getMailboxEmail } from "../utils"; +import { signInKeycloakIfNeeded } from "../utils-test"; + +test.describe("Message Max Recipients Per Message", () => { + // Clear storage state to force fresh authentication (ignore user.e2e from config) + test.use({ storageState: { cookies: [], origins: [] } }); + + test("should allow super admin to customize domain max recipients per message", async ({ page, browserName }) => { + // Login as super_admin (not superuser - check e2e_demo.py for correct username) + await page.goto("/"); + await signInKeycloakIfNeeded({ page, username: `super_admin.e2e.${browserName}` }); + + // Navigate to new message form + const moreOptionsButton = page.getByRole('button', { name: 'More options' }); + await moreOptionsButton.click(); + const manageMaildomainButton = page.getByRole('menuitem', { name: 'Domain admin' }); + await manageMaildomainButton.click(); + await page.waitForURL("/domain"); + await page.getByRole("heading", { name: "Maildomains management" }).waitFor({ state: "visible" }); + + // Update max recipients per message + const tuneLimitsButton = page.getByRole('button', { name: 'tune Settings' }); + await tuneLimitsButton.click(); + const maxRecipientsPerMessageInput = page.getByLabel("Maximum recipients per message"); + await maxRecipientsPerMessageInput.fill("50"); + const saveButton = page.getByRole('button', { name: 'Save' }); + await saveButton.click(); + await page.getByText('The domain settings have been updated!').waitFor({ state: "visible" }); + + // Go back to the maildomain list + const backToMaildomainsButton = page.getByRole('link', { name: 'mail', exact: true }); + await backToMaildomainsButton.click(); + await page.waitForURL("/mailbox/*"); + + // Change sender mailbox (click on the From field and select another mailbox) + const newMessageButton = page.getByRole("link", { name: "New message" }); + await newMessageButton.click(); + await page.waitForURL("/mailbox/*/new"); + await page.getByRole("heading", { name: "New message" }).waitFor({ state: "visible" }); + + // Check that the help text shows the new limit + const helpText = await page.locator('text=/maximum.*recipients/i').textContent(); + const limitMatch = helpText?.match(/\d+/); + const limit = limitMatch ? parseInt(limitMatch[0]) : null; + expect(limit).toBe(50); + }); + + test("should reject domain limit exceeding global maximum", async ({ page, browserName }) => { + // Login as super_admin + await page.goto("/"); + await signInKeycloakIfNeeded({ page, username: `super_admin.e2e.${browserName}` }); + + // Navigate to domain admin + const moreOptionsButton = page.getByRole('button', { name: 'More options' }); + await moreOptionsButton.click(); + const manageMaildomainButton = page.getByRole('menuitem', { name: 'Domain admin' }); + await manageMaildomainButton.click(); + await page.waitForURL("/domain"); + await page.getByRole("heading", { name: "Maildomains management" }).waitFor({ state: "visible" }); + + // Open settings modal + const tuneSettingsButton = page.getByRole('button', { name: 'tune Settings' }); + await tuneSettingsButton.click(); + await page.waitForTimeout(50); + const maxRecipientsPerMessageInput = page.getByLabel("Maximum recipients per message"); + await expect(maxRecipientsPerMessageInput).toBeVisible({ timeout: 5000 }); + + // Store current value to verify it's unchanged after failed save + const initialValue = await maxRecipientsPerMessageInput.inputValue(); + + // Try to set a limit exceeding global maximum (200) + await maxRecipientsPerMessageInput.fill("250"); + const saveButton = page.getByRole('button', { name: 'Save' }); + await saveButton.click(); + await page.getByText('The limit cannot exceed the global maximum of 200 recipients.').waitFor({ state: "visible" }); + + // Close the modal + await page.getByRole('button', { name: 'close' }).click(); + + // Verify the limit was not changed + await tuneSettingsButton.click(); + await page.waitForTimeout(50); + await expect(maxRecipientsPerMessageInput).toBeVisible({ timeout: 5000 }); + const finalValue = await maxRecipientsPerMessageInput.inputValue(); + expect(finalValue).toBe(initialValue); + }); + + test("should domain admin can customize mailbox max recipients per message ", async ({ page, browserName }) => { + // Login as domain admin + await page.goto("/"); + await signInKeycloakIfNeeded({ page, username: `domain_admin.e2e.${browserName}` }); + // Navigate to new message form + const moreOptionsButton = page.getByRole('button', { name: 'More options' }); + await moreOptionsButton.click(); + const manageMaildomainButton = page.getByRole('menuitem', { name: 'Domain admin' }); + await manageMaildomainButton.click(); + await page.waitForURL("/domain"); + // TODO: Implement this test + }); +}); diff --git a/src/e2e/src/__tests__/message-max-recipients.spec.ts b/src/e2e/src/__tests__/message-max-recipients.spec.ts new file mode 100644 index 000000000..c3b6f8bca --- /dev/null +++ b/src/e2e/src/__tests__/message-max-recipients.spec.ts @@ -0,0 +1,34 @@ +import test, { expect } from "@playwright/test"; +import { getMailboxEmail } from "../utils"; +import { signInKeycloakIfNeeded } from "../utils-test"; + +test.describe("Message Recipients Limit", () => { + test.beforeEach(async ({ page, browserName }) => { + await page.goto("/"); + await signInKeycloakIfNeeded({ page, username: `user.e2e.${browserName}` }); + + // Navigate to new message form + const newMessageButton = page.getByRole("link", { name: "New message" }); + await newMessageButton.click(); + await page.waitForURL("/mailbox/*/new"); + await page.getByRole("heading", { name: "New message" }).waitFor({ state: "visible" }); + }); + + test("should display max recipients help text for default mailbox", async ({ page, browserName }) => { + // Check that help text shows the limit for the default sender mailbox + const toFieldHelp = page.locator('text=/Maximum.*recipients/i'); + await expect(toFieldHelp).toBeVisible({ timeout: 10000 }); + + // The help text should contain a number (the limit) + const helpText = await toFieldHelp.textContent(); + expect(helpText).toMatch(/\d+/); // Should contain at least one digit + + // Extract the limit number from help text + const limitMatch = helpText?.match(/\d+/); + expect(limitMatch).toBeTruthy(); + const limit = parseInt(limitMatch![0]); + // FIXME: this is value set in message-max-recipients-admin.spec.ts + expect(limit).toBe(50); + }); + +}); diff --git a/src/frontend/src/features/api/gen/maildomains/maildomains.ts b/src/frontend/src/features/api/gen/maildomains/maildomains.ts index 085e8ae4f..078883f88 100644 --- a/src/frontend/src/features/api/gen/maildomains/maildomains.ts +++ b/src/frontend/src/features/api/gen/maildomains/maildomains.ts @@ -24,11 +24,14 @@ import type { import type { DNSCheckResponse, MailDomainAdmin, + MailDomainAdminUpdate, + MailDomainAdminUpdateRequest, MailDomainAdminWrite, MailDomainAdminWriteRequest, MailboxAdmin, MailboxAdminCreate, MailboxAdminCreatePayloadRequest, + MailboxSettingsUpdate, MaildomainsListParams, MaildomainsMailboxesListParams, MaildomainsMessageTemplatesListParams, @@ -36,7 +39,9 @@ import type { MessageTemplateRequest, PaginatedMailDomainAdminList, PaginatedMailboxAdminList, + PatchedMailDomainAdminUpdateRequest, PatchedMailboxAdminPartialUpdatePayloadRequest, + PatchedMailboxSettingsUpdateRequest, PatchedMessageTemplateRequest, ResetPasswordError, ResetPasswordInternalServerError, @@ -522,6 +527,214 @@ export function useMaildomainsRetrieve< return query; } +/** + * ViewSet for listing MailDomains the user administers. +Provides a top-level entry for mail domain administration. +Endpoint: /maildomains// + */ +export type maildomainsUpdateResponse200 = { + data: MailDomainAdminUpdate; + status: 200; +}; + +export type maildomainsUpdateResponseComposite = maildomainsUpdateResponse200; + +export type maildomainsUpdateResponse = maildomainsUpdateResponseComposite & { + headers: Headers; +}; + +export const getMaildomainsUpdateUrl = (maildomainPk: string) => { + return `/api/v1.0/maildomains/${maildomainPk}/`; +}; + +export const maildomainsUpdate = async ( + maildomainPk: string, + mailDomainAdminUpdateRequest: MailDomainAdminUpdateRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMaildomainsUpdateUrl(maildomainPk), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(mailDomainAdminUpdateRequest), + }, + ); +}; + +export const getMaildomainsUpdateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { maildomainPk: string; data: MailDomainAdminUpdateRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { maildomainPk: string; data: MailDomainAdminUpdateRequest }, + TContext +> => { + const mutationKey = ["maildomainsUpdate"]; + 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>, + { maildomainPk: string; data: MailDomainAdminUpdateRequest } + > = (props) => { + const { maildomainPk, data } = props ?? {}; + + return maildomainsUpdate(maildomainPk, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MaildomainsUpdateMutationResult = NonNullable< + Awaited> +>; +export type MaildomainsUpdateMutationBody = MailDomainAdminUpdateRequest; +export type MaildomainsUpdateMutationError = unknown; + +export const useMaildomainsUpdate = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { maildomainPk: string; data: MailDomainAdminUpdateRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { maildomainPk: string; data: MailDomainAdminUpdateRequest }, + TContext +> => { + const mutationOptions = getMaildomainsUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * ViewSet for listing MailDomains the user administers. +Provides a top-level entry for mail domain administration. +Endpoint: /maildomains// + */ +export type maildomainsPartialUpdateResponse200 = { + data: MailDomainAdminUpdate; + status: 200; +}; + +export type maildomainsPartialUpdateResponseComposite = + maildomainsPartialUpdateResponse200; + +export type maildomainsPartialUpdateResponse = + maildomainsPartialUpdateResponseComposite & { + headers: Headers; + }; + +export const getMaildomainsPartialUpdateUrl = (maildomainPk: string) => { + return `/api/v1.0/maildomains/${maildomainPk}/`; +}; + +export const maildomainsPartialUpdate = async ( + maildomainPk: string, + patchedMailDomainAdminUpdateRequest: PatchedMailDomainAdminUpdateRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMaildomainsPartialUpdateUrl(maildomainPk), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(patchedMailDomainAdminUpdateRequest), + }, + ); +}; + +export const getMaildomainsPartialUpdateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { maildomainPk: string; data: PatchedMailDomainAdminUpdateRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { maildomainPk: string; data: PatchedMailDomainAdminUpdateRequest }, + TContext +> => { + const mutationKey = ["maildomainsPartialUpdate"]; + 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>, + { maildomainPk: string; data: PatchedMailDomainAdminUpdateRequest } + > = (props) => { + const { maildomainPk, data } = props ?? {}; + + return maildomainsPartialUpdate(maildomainPk, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MaildomainsPartialUpdateMutationResult = NonNullable< + Awaited> +>; +export type MaildomainsPartialUpdateMutationBody = + PatchedMailDomainAdminUpdateRequest; +export type MaildomainsPartialUpdateMutationError = unknown; + +export const useMaildomainsPartialUpdate = < + TError = unknown, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { maildomainPk: string; data: PatchedMailDomainAdminUpdateRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { maildomainPk: string; data: PatchedMailDomainAdminUpdateRequest }, + TContext +> => { + const mutationOptions = getMaildomainsPartialUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; /** * Check DNS records for a specific mail domain. */ @@ -1545,6 +1758,147 @@ export const useMaildomainsMailboxesResetPassword = < return useMutation(mutationOptions, queryClient); }; +/** + * Update mailbox settings (custom_limits). + */ +export type maildomainsMailboxesSettingsUpdateResponse200 = { + data: MailboxSettingsUpdate; + status: 200; +}; + +export type maildomainsMailboxesSettingsUpdateResponse400 = { + data: void; + status: 400; +}; + +export type maildomainsMailboxesSettingsUpdateResponseComposite = + | maildomainsMailboxesSettingsUpdateResponse200 + | maildomainsMailboxesSettingsUpdateResponse400; + +export type maildomainsMailboxesSettingsUpdateResponse = + maildomainsMailboxesSettingsUpdateResponseComposite & { + headers: Headers; + }; + +export const getMaildomainsMailboxesSettingsUpdateUrl = ( + maildomainPk: string, + id: string, +) => { + return `/api/v1.0/maildomains/${maildomainPk}/mailboxes/${id}/settings/`; +}; + +export const maildomainsMailboxesSettingsUpdate = async ( + maildomainPk: string, + id: string, + patchedMailboxSettingsUpdateRequest: PatchedMailboxSettingsUpdateRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMaildomainsMailboxesSettingsUpdateUrl(maildomainPk, id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(patchedMailboxSettingsUpdateRequest), + }, + ); +}; + +export const getMaildomainsMailboxesSettingsUpdateMutationOptions = < + TError = void, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { + maildomainPk: string; + id: string; + data: PatchedMailboxSettingsUpdateRequest; + }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { + maildomainPk: string; + id: string; + data: PatchedMailboxSettingsUpdateRequest; + }, + TContext +> => { + const mutationKey = ["maildomainsMailboxesSettingsUpdate"]; + 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>, + { + maildomainPk: string; + id: string; + data: PatchedMailboxSettingsUpdateRequest; + } + > = (props) => { + const { maildomainPk, id, data } = props ?? {}; + + return maildomainsMailboxesSettingsUpdate( + maildomainPk, + id, + data, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MaildomainsMailboxesSettingsUpdateMutationResult = NonNullable< + Awaited> +>; +export type MaildomainsMailboxesSettingsUpdateMutationBody = + PatchedMailboxSettingsUpdateRequest; +export type MaildomainsMailboxesSettingsUpdateMutationError = void; + +export const useMaildomainsMailboxesSettingsUpdate = < + TError = void, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { + maildomainPk: string; + id: string; + data: PatchedMailboxSettingsUpdateRequest; + }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { + maildomainPk: string; + id: string; + data: PatchedMailboxSettingsUpdateRequest; + }, + TContext +> => { + const mutationOptions = + getMaildomainsMailboxesSettingsUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; /** * List message templates for a maildomain. */ diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 1b0f1b816..b64f71dda 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -26,4 +26,8 @@ export type ConfigRetrieve200 = { readonly MAX_OUTGOING_BODY_SIZE: number; /** Maximum size in bytes for incoming email (including attachments and body) */ readonly MAX_INCOMING_EMAIL_SIZE: number; + /** Maximum number of recipients per message (to + cc + bcc) for the entire system. Cannot be exceeded. */ + readonly MAX_RECIPIENTS_PER_MESSAGE: number; + /** Default maximum number of recipients per message (to + cc + bcc) for a mailbox or maildomain if no custom limit is set. */ + readonly MAX_DEFAULT_RECIPIENTS_PER_MESSAGE: number; }; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index a0a5ef0ec..5a5dc7eac 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -50,6 +50,9 @@ export * from "./labels_remove_threads_create_body"; export * from "./mail_domain_access_role_choices"; export * from "./mail_domain_admin"; export * from "./mail_domain_admin_abilities"; +export * from "./mail_domain_admin_custom_limits"; +export * from "./mail_domain_admin_update"; +export * from "./mail_domain_admin_update_request"; export * from "./mail_domain_admin_write"; export * from "./mail_domain_admin_write_request"; export * from "./mailbox"; @@ -62,9 +65,11 @@ export * from "./mailbox_admin"; export * from "./mailbox_admin_create"; export * from "./mailbox_admin_create_metadata_request"; export * from "./mailbox_admin_create_payload_request"; +export * from "./mailbox_admin_custom_limits"; export * from "./mailbox_admin_update_metadata_request"; export * from "./mailbox_light"; export * from "./mailbox_role_choices"; +export * from "./mailbox_settings_update"; export * from "./mailboxes_accesses_list_params"; export * from "./mailboxes_message_templates_available_list_params"; export * from "./mailboxes_message_templates_available_list_type"; @@ -99,8 +104,10 @@ export * from "./paginated_thread_access_list"; export * from "./paginated_thread_list"; export * from "./partial_drive_item"; export * from "./patched_label_request"; +export * from "./patched_mail_domain_admin_update_request"; export * from "./patched_mailbox_access_write_request"; export * from "./patched_mailbox_admin_partial_update_payload_request"; +export * from "./patched_mailbox_settings_update_request"; export * from "./patched_message_template_request"; export * from "./patched_thread_access_request"; export * from "./placeholders_retrieve200"; diff --git a/src/frontend/src/features/api/gen/models/mail_domain_admin.ts b/src/frontend/src/features/api/gen/models/mail_domain_admin.ts index 7824425f6..0d234d1c6 100644 --- a/src/frontend/src/features/api/gen/models/mail_domain_admin.ts +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin.ts @@ -5,6 +5,7 @@ * This is the messages API schema. * OpenAPI spec version: 1.0.0 (v1.0) */ +import type { MailDomainAdminCustomLimits } from "./mail_domain_admin_custom_limits"; import type { MailDomainAdminAbilities } from "./mail_domain_admin_abilities"; /** @@ -19,6 +20,11 @@ export interface MailDomainAdmin { /** date and time at which a record was last updated */ readonly updated_at: string; readonly expected_dns_records: string; + /** + * Limits applied to this mail domain (e.g. max recipients per message). + * @nullable + */ + readonly custom_limits: MailDomainAdminCustomLimits; /** Instance permissions and capabilities */ readonly abilities: MailDomainAdminAbilities; } diff --git a/src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts b/src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts index daedafc48..042d3c3f1 100644 --- a/src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts @@ -24,4 +24,6 @@ export type MailDomainAdminAbilities = { readonly manage_accesses: boolean; /** Can manage mailboxes */ readonly manage_mailboxes: boolean; + /** Can manage settings */ + readonly manage_settings: boolean; }; diff --git a/src/frontend/src/features/api/gen/models/mail_domain_admin_custom_limits.ts b/src/frontend/src/features/api/gen/models/mail_domain_admin_custom_limits.ts new file mode 100644 index 000000000..53e2da80e --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin_custom_limits.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Limits applied to this mail domain (e.g. max recipients per message). + * @nullable + */ +export type MailDomainAdminCustomLimits = { + /** + * Maximum number of recipients per message for this domain. + * @nullable + */ + readonly max_recipients_per_message?: number | null; +} | null; diff --git a/src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts b/src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts new file mode 100644 index 000000000..46eb1e170 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize mail domains for updating custom_limits only. + */ +export interface MailDomainAdminUpdate { + /** primary key for the record as UUID */ + readonly id: string; + /** Limits applied to this mail domain (e.g. max recipients per message). */ + custom_limits?: unknown; +} diff --git a/src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts b/src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts new file mode 100644 index 000000000..f672dfec8 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize mail domains for updating custom_limits only. + */ +export interface MailDomainAdminUpdateRequest { + /** Limits applied to this mail domain (e.g. max recipients per message). */ + custom_limits?: unknown; +} diff --git a/src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts b/src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts index 752c799de..8ec3f8e32 100644 --- a/src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts @@ -7,7 +7,7 @@ */ /** - * Serialize mail domains for creating / editing admin view. + * Serialize mail domains for creating admin view. */ export interface MailDomainAdminWrite { /** primary key for the record as UUID */ diff --git a/src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts b/src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts index 4a2c1cfed..a435eb791 100644 --- a/src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts @@ -7,7 +7,7 @@ */ /** - * Serialize mail domains for creating / editing admin view. + * Serialize mail domains for creating admin view. */ export interface MailDomainAdminWriteRequest { /** diff --git a/src/frontend/src/features/api/gen/models/mailbox.ts b/src/frontend/src/features/api/gen/models/mailbox.ts index b339aed0a..316454786 100644 --- a/src/frontend/src/features/api/gen/models/mailbox.ts +++ b/src/frontend/src/features/api/gen/models/mailbox.ts @@ -18,6 +18,7 @@ export interface Mailbox { readonly role: MailboxRoleChoices; readonly count_unread_messages: string; readonly count_messages: string; + readonly max_recipients_per_message: number; /** Instance permissions and capabilities */ readonly abilities: MailboxAbilities; } diff --git a/src/frontend/src/features/api/gen/models/mailbox_abilities.ts b/src/frontend/src/features/api/gen/models/mailbox_abilities.ts index 4405e35cf..61e0f3a3b 100644 --- a/src/frontend/src/features/api/gen/models/mailbox_abilities.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_abilities.ts @@ -32,4 +32,6 @@ export type MailboxAbilities = { readonly manage_message_templates: boolean; /** Can import messages */ readonly import_messages: boolean; + /** Can manage settings */ + readonly manage_settings: boolean; }; diff --git a/src/frontend/src/features/api/gen/models/mailbox_admin.ts b/src/frontend/src/features/api/gen/models/mailbox_admin.ts index 77719259d..af00098e9 100644 --- a/src/frontend/src/features/api/gen/models/mailbox_admin.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_admin.ts @@ -7,6 +7,7 @@ */ import type { MailboxAccessNestedUser } from "./mailbox_access_nested_user"; import type { Contact } from "./contact"; +import type { MailboxAdminCustomLimits } from "./mailbox_admin_custom_limits"; /** * Serialize Mailbox details for admin view, including users with access. @@ -34,4 +35,9 @@ export interface MailboxAdmin { readonly updated_at: string; readonly can_reset_password: boolean; readonly contact: Contact; + /** + * Limits applied to this mailbox (e.g. max recipients per message). + * @nullable + */ + readonly custom_limits: MailboxAdminCustomLimits; } diff --git a/src/frontend/src/features/api/gen/models/mailbox_admin_create.ts b/src/frontend/src/features/api/gen/models/mailbox_admin_create.ts index b358a7a98..adbea064e 100644 --- a/src/frontend/src/features/api/gen/models/mailbox_admin_create.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_admin_create.ts @@ -11,6 +11,9 @@ import type { Contact } from "./contact"; /** * Serialize Mailbox details for create admin endpoint, including users with access and metadata. + +Note: custom_limits is excluded from the response as it cannot be set during creation. +It can only be modified via the dedicated /settings/ endpoint. */ export interface MailboxAdminCreate { /** primary key for the record as UUID */ diff --git a/src/frontend/src/features/api/gen/models/mailbox_admin_custom_limits.ts b/src/frontend/src/features/api/gen/models/mailbox_admin_custom_limits.ts new file mode 100644 index 000000000..51bd5fe7d --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_admin_custom_limits.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Limits applied to this mailbox (e.g. max recipients per message). + * @nullable + */ +export type MailboxAdminCustomLimits = { + /** + * Maximum number of recipients per message for this mailbox. + * @nullable + */ + readonly max_recipients_per_message?: number | null; +} | null; diff --git a/src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts b/src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts index 3ae608b17..c59f40e62 100644 --- a/src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts @@ -10,4 +10,5 @@ export interface MailboxAdminUpdateMetadataRequest { full_name?: string; name?: string; custom_attributes?: unknown; + custom_limits?: unknown; } diff --git a/src/frontend/src/features/api/gen/models/mailbox_settings_update.ts b/src/frontend/src/features/api/gen/models/mailbox_settings_update.ts new file mode 100644 index 000000000..c52b4a15c --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_settings_update.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize mailbox settings (custom_limits) for update operations. + */ +export interface MailboxSettingsUpdate { + /** primary key for the record as UUID */ + readonly id: string; + /** Limits applied to this mailbox (e.g. max recipients per message). */ + custom_limits?: unknown; +} diff --git a/src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts b/src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts new file mode 100644 index 000000000..1f4d503e2 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize mail domains for updating custom_limits only. + */ +export interface PatchedMailDomainAdminUpdateRequest { + /** Limits applied to this mail domain (e.g. max recipients per message). */ + custom_limits?: unknown; +} diff --git a/src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts b/src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts new file mode 100644 index 000000000..83f40d0e5 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize mailbox settings (custom_limits) for update operations. + */ +export interface PatchedMailboxSettingsUpdateRequest { + /** Limits applied to this mailbox (e.g. max recipients per message). */ + custom_limits?: unknown; +} diff --git a/src/frontend/src/features/forms/components/message-form/index.tsx b/src/frontend/src/features/forms/components/message-form/index.tsx index ab21d06ac..43e329cff 100644 --- a/src/frontend/src/features/forms/components/message-form/index.tsx +++ b/src/frontend/src/features/forms/components/message-form/index.tsx @@ -25,6 +25,7 @@ import i18n from "@/features/i18n/initI18n"; import { DropdownButton } from "@/features/ui/components/dropdown-button"; import { PREFER_SEND_MODE_KEY, PreferSendMode } from "@/features/config/constants"; import { useSearchParams } from "next/navigation"; +import { useConfig } from "@/features/providers/config"; export type MessageFormMode = "new" | "reply" | "reply_all" | "forward"; @@ -53,7 +54,7 @@ const driveAttachmentSchema = z.object({ size: z.number(), created_at: z.string(), }); -const messageFormSchema = z.object({ +const getMessageFormSchema = (maxRecipients?: number) => z.object({ from: z.string().nonempty({ error: i18n.t("Mailbox is required.") }), to: emailArraySchema, cc: emailArraySchema.optional(), @@ -65,6 +66,38 @@ const messageFormSchema = z.object({ attachments: z.array(attachmentSchema).optional(), driveAttachments: z.array(driveAttachmentSchema).optional(), signatureId: z.string().optional().nullable(), +}).superRefine((data, ctx) => { + if (!maxRecipients) return; + + const totalRecipients = data.to.length + (data.cc?.length ?? 0) + (data.bcc?.length ?? 0); + if (totalRecipients > maxRecipients) { + const message = i18n.t("You can add up to {{max}} recipients in total (to + cc + bcc).", { + max: maxRecipients, + }); + + // Add issue to all recipient fields + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: ["to"], + }); + + if ((data.cc?.length ?? 0) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: ["cc"], + }); + } + + if ((data.bcc?.length ?? 0) > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: ["bcc"], + }); + } + } }); const DRAFT_TOAST_ID = "MESSAGE_FORM_DRAFT_TOAST"; @@ -79,6 +112,10 @@ export const MessageForm = ({ const { t } = useTranslation(); const router = useRouter(); const searchParams = useSearchParams(); + const config = useConfig(); + + const { selectedMailbox, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext(); + const [draft, setDraft] = useState(draftMessage); const [preferredSendMode, setPreferredSendMode] = useState(() => { if (mode === 'new') return PreferSendMode.SEND; @@ -108,7 +145,6 @@ export const MessageForm = ({ const [currentTime, setCurrentTime] = useState(new Date()); const autoSaveTimerRef = useRef(null); const quoteType: QuoteType | undefined = mode !== "new" ? (mode === "forward" ? "forward" : "reply") : undefined; - const { selectedMailbox, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext(); const hideSubjectField = Boolean(parentMessage); const defaultSenderId = mailboxes?.find((mailbox) => { if (draft?.sender) return draft.sender.email === mailbox.email; @@ -189,14 +225,59 @@ export const MessageForm = ({ } }, [draft, selectedMailbox]) + // State to track the current max recipients limit + const [maxRecipientsLimit, setMaxRecipientsLimit] = useState(() => { + const initialMailbox = mailboxes?.find((mb) => mb.id === formDefaultValues.from); + const defaultLimit = Number(config.MAX_DEFAULT_RECIPIENTS_PER_MESSAGE ?? config.MAX_RECIPIENTS_PER_MESSAGE); + const mailboxLimit = initialMailbox?.max_recipients_per_message ?? selectedMailbox?.max_recipients_per_message; + return mailboxLimit ? Number(mailboxLimit) : defaultLimit; + }); + + // Create schema with current limit + const schema = useMemo(() => getMessageFormSchema(maxRecipientsLimit), [maxRecipientsLimit]); + + // Memoize the resolver so it updates when schema changes + const resolver = useMemo(() => zodResolver(schema), [schema]); + const form = useForm({ - resolver: zodResolver(messageFormSchema), - mode: "onBlur", - reValidateMode: "onBlur", + resolver, + mode: "onChange", + reValidateMode: "onChange", shouldFocusError: false, defaultValues: formDefaultValues, }); + // Watch the "from" field to dynamically update max recipients limit + const selectedFromMailboxId = useWatch({ + control: form.control, + name: "from", + }); + + // Update max recipients limit when sender mailbox changes + useEffect(() => { + if (!selectedFromMailboxId || !mailboxes) return; + + const senderMailbox = mailboxes.find((mailbox) => mailbox.id === selectedFromMailboxId); + const defaultLimit = Number(config.MAX_DEFAULT_RECIPIENTS_PER_MESSAGE ?? config.MAX_RECIPIENTS_PER_MESSAGE); + const mailboxLimit = senderMailbox?.max_recipients_per_message; + const newLimit = mailboxLimit ? Number(mailboxLimit) : defaultLimit; + + if (newLimit !== maxRecipientsLimit) { + setMaxRecipientsLimit(newLimit); + } + }, [selectedFromMailboxId, mailboxes, config, maxRecipientsLimit]); + + // Revalidate recipient fields when max limit changes + useEffect(() => { + // Only trigger validation if fields have values + const values = form.getValues(); + const hasRecipients = (values.to?.length ?? 0) > 0 || (values.cc?.length ?? 0) > 0 || (values.bcc?.length ?? 0) > 0; + + if (hasRecipients) { + form.trigger(['to', 'cc', 'bcc']); + } + }, [maxRecipientsLimit, form]); + const messageDraftBody = useWatch({ control: form.control, name: "messageDraftBody", @@ -395,7 +476,7 @@ export const MessageForm = ({ let response; try { stopAutoSave(); - form.reset(form.getValues(), { keepSubmitCount: true, keepDirty: false, keepValues: true, keepDefaultValues: false }); + form.reset(form.getValues(), { keepSubmitCount: true, keepDirty: false, keepValues: true, keepDefaultValues: false, keepErrors: true }); if (!draft) { response = await draftCreateMutation.mutateAsync({ data: payload, @@ -432,6 +513,7 @@ export const MessageForm = ({ form.setError("to", { message: t("At least one recipient is required.") }); return; } + if (!draft || !canSendMessages) return; messageMutation.mutate({ @@ -531,6 +613,7 @@ export const MessageForm = ({ group} text={form.formState.errors.to && !Array.isArray(form.formState.errors.to) ? form.formState.errors.to.message : t("Enter the email addresses of the recipients separated by commas")} textItems={Array.isArray(form.formState.errors.to) ? form.formState.errors.to?.map((error, index) => t(error!.message as string, { email: form.getValues('to')?.[index] })) : []} @@ -547,6 +630,7 @@ export const MessageForm = ({ group} text={form.formState.errors.cc && !Array.isArray(form.formState.errors.cc) ? t(form.formState.errors.cc.message as string) : t("Enter the email addresses of the recipients separated by commas")} textItems={Array.isArray(form.formState.errors.cc) ? form.formState.errors.cc?.map((error, index) => t(error!.message as string, { email: form.getValues('cc')?.[index] })) : []} @@ -562,6 +646,7 @@ export const MessageForm = ({ visibility_off} text={form.formState.errors.bcc && !Array.isArray(form.formState.errors.bcc) ? t(form.formState.errors.bcc.message as string) : t("Enter the email addresses of the recipients separated by commas")} textItems={Array.isArray(form.formState.errors.bcc) ? form.formState.errors.bcc?.map((error, index) => t(error!.message as string, { email: form.getValues('bcc')?.[index] })) : []} diff --git a/src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx b/src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx index 9b5f88356..06f9a77c3 100644 --- a/src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx +++ b/src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx @@ -5,11 +5,15 @@ import { useMailboxContext } from "@/features/providers/mailbox"; import { UserRow } from "@gouvfr-lasuite/ui-kit"; import { Controller, useFormContext } from "react-hook-form"; import MailHelper from "@/features/utils/mail-helper"; +import { useConfig } from "@/features/providers/config"; +import { useTranslation } from "react-i18next"; -export const RhfContactComboBox = (props: Omit & { name: string }) => { - const { control, setValue } = useFormContext(); +export const RhfContactComboBox = (props: Omit & { name: string; maxRecipients?: number }) => { + const { control, setValue, formState, trigger } = useFormContext(); const [searchQuery, setSearchQuery] = useState(""); const { selectedMailbox } = useMailboxContext(); + const MAX_RECIPIENTS_PER_MESSAGE = props.maxRecipients ?? selectedMailbox?.max_recipients_per_message; + const { t } = useTranslation(); const contactsQuery = useContactsList({ mailbox_id: selectedMailbox?.id }, { query: { enabled: !!selectedMailbox?.id, @@ -45,20 +49,35 @@ export const RhfContactComboBox = (props: Omit & { nam ( - setValue(props.name, value, { shouldDirty: true })} - onInputChange={(value) => setSearchQuery(value.trim())} - options={contactsOptions} - /> - )} + render={({ field, fieldState }) => { + // Use formState.errors directly to ensure reactivity to external setError calls + const fieldError = formState.errors[props.name as keyof typeof formState.errors]; + const baseHelperText = + MAX_RECIPIENTS_PER_MESSAGE + ? t("Enter the email addresses of the recipients, maximum {{max}} for all recipients (to + cc + bcc)", { max: MAX_RECIPIENTS_PER_MESSAGE }) + : t("Enter the email addresses of the recipients"); + + return ( + { + // Update the value of the field with validation + setValue(props.name, value, { shouldDirty: true, shouldValidate: true }); + // Trigger validation for other recipient fields to update their error state + trigger(['to', 'cc', 'bcc']); + }} + onInputChange={(value) => setSearchQuery(value.trim())} + options={contactsOptions} + /> + ); + }} /> ) } diff --git a/src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx b/src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx index 05db507f1..6f6d1bbd8 100644 --- a/src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx +++ b/src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx @@ -103,7 +103,13 @@ const JSONSchemaInput = ({ name, type, choices, format, value, setValue, ...prop {...props} type="number" name={name} - onChange={(event) => { setValue(name, event.target.value, { shouldDirty: true })}} + onChange={(event) => { + const val = event.target.value; + // Convert empty string to null to avoid validation error "expected number, received string" + // Convert to number otherwise + const numVal = val === "" ? null : Number(val); + setValue(name, numVal, { shouldDirty: true }) + }} value={value as InputProps['value']} /> ); diff --git a/src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx b/src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx new file mode 100644 index 000000000..a3b5dff10 --- /dev/null +++ b/src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx @@ -0,0 +1,35 @@ +import { ModalUpdateDomain } from "@/features/layouts/components/admin/modal-maildomain-manage-settings"; +import { useAdminMailDomain } from "@/features/providers/admin-maildomain"; +import useAbility, { Abilities } from "@/hooks/use-ability"; +import { Button, useModal } from "@openfun/cunningham-react"; +import { useTranslation } from "react-i18next"; + +type DomainManageSettingsActionProps = { + onSuccess: () => void; +} + +export const DomainManageSettingsAction = ({ onSuccess }: DomainManageSettingsActionProps) => { + const modal = useModal(); + const { t } = useTranslation(); + const { selectedMailDomain } = useAdminMailDomain(); + const canManageMaildomainSettings = useAbility(Abilities.CAN_MANAGE_MAILDOMAIN_SETTINGS, selectedMailDomain); + + if (!canManageMaildomainSettings || !selectedMailDomain) { + return null; + } + + return ( + <> + + + + ) +} + diff --git a/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx b/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx index ff9fd9d8f..95d1cbbe0 100644 --- a/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx +++ b/src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx @@ -1,5 +1,6 @@ import { MailboxAdmin, MailDomainAdmin, useMaildomainsMailboxesDestroy, useMaildomainsMailboxesList } from "@/features/api/gen"; import { ModalMailboxManageAccesses } from "@/features/layouts/components/admin/modal-mailbox-manage-accesses"; +import { ModalMailboxManageSettings } from "@/features/layouts/components/admin/modal-mailbox-manage-settings"; import { Banner } from "@/features/ui/components/banner"; import useAbility, { Abilities } from "@/hooks/use-ability"; import { DropdownMenu, Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; @@ -20,6 +21,7 @@ enum MailboxEditAction { UPDATE = 'update', RESET_PASSWORD = 'resetPassword', MANAGE_ACCESS = 'manageAccess', + MANAGE_SETTINGS = 'manageSettings', } export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridProps) => { @@ -55,6 +57,11 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr setEditedMailbox(mailbox); } + const handleManageSettings = (mailbox: MailboxAdmin) => { + setEditAction(MailboxEditAction.MANAGE_SETTINGS); + setEditedMailbox(mailbox); + } + const handleDelete = async (mailbox: MailboxAdmin) => { const email = MailboxHelper.toString(mailbox); const decision = await modals.deleteConfirmationModal({ @@ -140,6 +147,7 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr onResetPassword={row.can_reset_password ? () => handleResetPassword(row) : undefined} onDelete={() => handleDelete(row)} onUpdate={() => handleUpdate(row)} + onManageSettings={() => handleManageSettings(row)} />, }] : []), ]; @@ -207,6 +215,14 @@ export const AdminMailboxDataGrid = ({ domain, pagination }: AdminUserDataGridPr mailbox={editedMailbox} domainId={domain.id} /> + handleCloseEditUserModal(false)} + onSuccess={refetchMailboxes} + mailbox={editedMailbox} + domainId={domain.id} + /> )} @@ -218,9 +234,10 @@ type ActionsRowProps = { onResetPassword?: () => void; onDelete: () => void; onUpdate: () => void; + onManageSettings: () => void; }; -const ActionsRow = ({ onManageAccess, onResetPassword, onDelete, onUpdate }: ActionsRowProps) => { +const ActionsRow = ({ onManageAccess, onResetPassword, onDelete, onUpdate, onManageSettings }: ActionsRowProps) => { const [isMoreActionsOpen, setMoreActionsOpen] = useState(false); const { t } = useTranslation(); @@ -242,6 +259,11 @@ const ActionsRow = ({ onManageAccess, onResetPassword, onDelete, onUpdate }: Act label: t('Edit'), icon: , callback: onUpdate, + }, + { + label: t('Settings'), + icon: , + callback: onManageSettings, showSeparator: !onResetPassword, }, ...(onResetPassword ? [{ diff --git a/src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx b/src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx new file mode 100644 index 000000000..60b974880 --- /dev/null +++ b/src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx @@ -0,0 +1,143 @@ +import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { MailboxAdmin, useMaildomainsMailboxesSettingsUpdate } from '@/features/api/gen'; +import { Banner } from '@/features/ui/components/banner'; +import { useConfig } from '@/features/providers/config'; +import { addToast, ToasterItem } from '@/features/ui/components/toaster'; +import { Icon } from '@gouvfr-lasuite/ui-kit'; +import MailboxHelper from '@/features/utils/mailbox-helper'; + +type ModalMailboxManageSettingsProps = { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + mailbox: MailboxAdmin; + domainId: string; +} + +type UpdateMailboxFormData = { + max_recipients_per_message: string; +}; + +export const ModalMailboxManageSettings = ({ isOpen, onClose, onSuccess, mailbox, domainId }: ModalMailboxManageSettingsProps) => { + + const { t } = useTranslation(); + const [error, setError] = useState(null); + + const { MAX_RECIPIENTS_PER_MESSAGE } = useConfig(); + + const { mutateAsync: updateMailboxSettings, isPending: isSubmitting } = useMaildomainsMailboxesSettingsUpdate(); + + const form = useForm({ + defaultValues: { + max_recipients_per_message: mailbox.custom_limits?.max_recipients_per_message?.toString() ?? '', + }, + }); + + const { handleSubmit, register, formState: { errors }, watch } = form; + const maxRecipientsValue = watch('max_recipients_per_message'); + + const handleClose = () => { + form.reset(); + setError(null); + onClose(); + }; + + const onSubmit = async (data: UpdateMailboxFormData) => { + setError(null); + + // Validate + const value = data.max_recipients_per_message.trim(); + if (value !== '' && (isNaN(Number(value)) || Number(value) < 1 || !Number.isInteger(Number(value)))) { + form.setError('max_recipients_per_message', { message: t('Please enter a valid positive integer or leave empty.') }); + return; + } + + // Validate that the value does not exceed the global maximum + if (value !== '' && Number(value) > MAX_RECIPIENTS_PER_MESSAGE) { + form.setError('max_recipients_per_message', { + message: t('The limit cannot exceed the global maximum of {{max}} recipients.', { max: MAX_RECIPIENTS_PER_MESSAGE }) + }); + return; + } + + try { + // Use the dedicated /settings/ endpoint for custom_limits + await updateMailboxSettings({ + maildomainPk: domainId, + id: mailbox.id, + data: { + custom_limits: { + max_recipients_per_message: value === '' ? null : Number(value), + }, + }, + }); + + onSuccess(); + addToast( + + + {t('The mailbox settings have been updated!')} + , { + toastId: "toast_edit_mailbox_settings_success", + } + ); + handleClose(); + + } catch (err: any) { + const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox settings.'); + setError(errorMessage); + } + }; + + const mailboxEmail = MailboxHelper.toString(mailbox); + + const helperText = errors.max_recipients_per_message?.message + ? errors.max_recipients_per_message.message + : t('Leave empty to use the domain or global default. Maximum: {{value}}', { value: MAX_RECIPIENTS_PER_MESSAGE }); + + return ( + +
+
+ {error && ( + + {t(error)} + + )} + +
+ +
+ +
+ +
+
+
+
+ ) +} diff --git a/src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx b/src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx new file mode 100644 index 000000000..e65a50ce4 --- /dev/null +++ b/src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx @@ -0,0 +1,153 @@ +import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { MailDomainAdmin } from '@/features/api/gen'; +import { Banner } from '@/features/ui/components/banner'; +import { useConfig } from '@/features/providers/config'; +import { fetchAPI } from '@/features/api/fetch-api'; +import { useQueryClient } from '@tanstack/react-query'; +import { addToast, ToasterItem } from '@/features/ui/components/toaster'; +import { Icon } from '@gouvfr-lasuite/ui-kit'; + +type ModalUpdateDomainProps = { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + domain: MailDomainAdmin; +} + +type UpdateDomainFormData = { + max_recipients_per_message: string; +}; + +export const ModalUpdateDomain = ({ isOpen, onClose, onSuccess, domain }: ModalUpdateDomainProps) => { + + const { t } = useTranslation(); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const queryClient = useQueryClient(); + + const { MAX_RECIPIENTS_PER_MESSAGE } = useConfig(); + + const form = useForm({ + defaultValues: { + max_recipients_per_message: domain.custom_limits?.max_recipients_per_message?.toString() ?? '', + }, + }); + + const { handleSubmit, register, formState: { errors }, watch } = form; + const maxRecipientsValue = watch('max_recipients_per_message'); + + const handleClose = () => { + form.reset(); + setError(null); + onClose(); + }; + + const onSubmit = async (data: UpdateDomainFormData) => { + setError(null); + + // Validate + const value = data.max_recipients_per_message.trim(); + if (value !== '' && (isNaN(Number(value)) || Number(value) < 1 || !Number.isInteger(Number(value)))) { + form.setError('max_recipients_per_message', { message: t('Please enter a valid positive integer or leave empty.') }); + return; + } + + // Validate that the value does not exceed the global maximum + if (value !== '' && Number(value) > MAX_RECIPIENTS_PER_MESSAGE) { + form.setError('max_recipients_per_message', { + message: t('The limit cannot exceed the global maximum of {{max}} recipients.', { max: MAX_RECIPIENTS_PER_MESSAGE }) + }); + return; + } + + setIsSubmitting(true); + try { + const payload = { + custom_limits: { + max_recipients_per_message: value === '' ? null : Number(value), + }, + }; + + await fetchAPI(`/api/v1.0/maildomains/${domain.id}/`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + // Invalidate queries - use predicate to match all maildomains queries + await queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey[0]; + return typeof key === 'string' && key.includes('/api/v1.0/maildomains'); + } + }); + + onSuccess(); + addToast( + + + {t('The domain settings have been updated!')} + , { + toastId: "toast_edit_domain_modal_success", + } + ); + handleClose(); + + } catch (err: any) { + const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the domain settings.'); + setError(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + const helperText = errors.max_recipients_per_message?.message + ? errors.max_recipients_per_message.message + : t('Leave empty to use the global default. Maximum: {{value}}', { value: MAX_RECIPIENTS_PER_MESSAGE }); + + return ( + +
+
+ {error && ( + + {t(error)} + + )} + +
+ +
+ +
+ +
+
+
+
+ ) +} diff --git a/src/frontend/src/features/providers/config.tsx b/src/frontend/src/features/providers/config.tsx index 868e3fe63..e308d1c2f 100644 --- a/src/frontend/src/features/providers/config.tsx +++ b/src/frontend/src/features/providers/config.tsx @@ -23,7 +23,9 @@ const DEFAULT_CONFIG: AppConfig = { MAX_OUTGOING_ATTACHMENT_SIZE: 0, MAX_OUTGOING_BODY_SIZE: 0, MAX_INCOMING_EMAIL_SIZE: 0, - DRIVE: DEFAULT_DRIVE_CONFIG + DRIVE: DEFAULT_DRIVE_CONFIG, + MAX_RECIPIENTS_PER_MESSAGE: 0, // Will be set by backend config + MAX_DEFAULT_RECIPIENTS_PER_MESSAGE: 0, // Will be set by backend config } const ConfigContext = createContext(DEFAULT_CONFIG) diff --git a/src/frontend/src/hooks/use-ability.ts b/src/frontend/src/hooks/use-ability.ts index 5a17d41cf..170f4803f 100644 --- a/src/frontend/src/hooks/use-ability.ts +++ b/src/frontend/src/hooks/use-ability.ts @@ -12,6 +12,7 @@ enum MailboxAbilities { CAN_MANAGE_MAILBOX_LABELS = "manage_labels", CAN_IMPORT_MESSAGES = "import_messages", CAN_MANAGE_MESSAGE_TEMPLATES = "manage_message_templates", + CAN_MANAGE_SETTINGS = "manage_settings", } enum UserAbilities { @@ -23,6 +24,7 @@ enum UserAbilities { enum MaildomainAbilities { CAN_MANAGE_MAILDOMAIN_MAILBOXES = "manage_mailboxes", CAN_MANAGE_MAILDOMAIN_ACCESSES = "manage_accesses", + CAN_MANAGE_MAILDOMAIN_SETTINGS = "manage_settings", } enum ThreadAccessAbilities { @@ -79,7 +81,9 @@ function useAbility( case Abilities.CAN_MANAGE_MESSAGE_TEMPLATES: case Abilities.CAN_MANAGE_MAILDOMAIN_MAILBOXES: case Abilities.CAN_MANAGE_MAILDOMAIN_ACCESSES: + case Abilities.CAN_MANAGE_MAILDOMAIN_SETTINGS: case Abilities.CAN_MANAGE_SOME_MAILDOMAIN_ACCESSES: + case Abilities.CAN_MANAGE_SETTINGS: return (resource as ResourceWithAbilities).abilities[ability] === true; case Abilities.CAN_MANAGE_THREAD_ACCESS: const [mailbox, thread] = resource as [Mailbox, Thread]; diff --git a/src/frontend/src/pages/domain/index.tsx b/src/frontend/src/pages/domain/index.tsx index 1e2459b4d..c463d9236 100644 --- a/src/frontend/src/pages/domain/index.tsx +++ b/src/frontend/src/pages/domain/index.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; -import { Button, DataGrid, usePagination } from "@openfun/cunningham-react"; +import { Button, DataGrid, usePagination, useModal } from "@openfun/cunningham-react"; import { useRouter } from "next/router"; import { Trans, useTranslation } from "react-i18next"; -import { Spinner } from "@gouvfr-lasuite/ui-kit"; +import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; import { AdminLayout } from "@/features/layouts/components/admin/admin-layout"; import Bar from "@/features/ui/components/bar"; import { getMaildomainsListQueryOptions, MailDomainAdmin, MailDomainAdminWrite } from "@/features/api/gen"; @@ -13,6 +13,7 @@ import { CreateDomainAction } from "@/features/layouts/components/admin/domains- import { useQueryClient } from "@tanstack/react-query"; import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import { ModalMaildomainManageAccesses } from "@/features/layouts/components/admin/modal-maildomain-manage-accesses"; +import { ModalUpdateDomain } from "@/features/layouts/components/admin/modal-maildomain-manage-settings"; type AdminDataGridProps = { pagination: ReturnType; @@ -21,12 +22,21 @@ type AdminDataGridProps = { enum MailDomainEditAction { MANAGE_ACCESS = 'manageAccess', + MANAGE_SETTINGS = 'manageSettings', } function AdminDataGrid({ domains, pagination }: AdminDataGridProps) { const router = useRouter(); const { t, i18n } = useTranslation(); const canManageMaildomainAccesses = useAbility(Abilities.CAN_MANAGE_SOME_MAILDOMAIN_ACCESSES); + const queryClient = useQueryClient(); + + const refetchMaildomains = () => { + queryClient.invalidateQueries({ + queryKey: getMaildomainsListQueryOptions().queryKey, + exact: false, + }); + }; const [editedDomain, setEditedDomain] = useState(null); const [editAction, setEditAction] = useState(null); const columns = [ @@ -52,9 +62,9 @@ function AdminDataGrid({ domains, pagination }: AdminDataGridProps) { headerName: t("Updated at"), renderCell: ({ row }: { row: MailDomainAdmin }) => new Date(row.updated_at).toLocaleDateString(i18n.resolvedLanguage), }, - ...(canManageMaildomainAccesses ? [{ + { id: "actions", - size: 130, + size: 200, headerName: t("Actions"), renderCell: ({ row }: { row: MailDomainAdmin }) => ( { setEditAction(MailDomainEditAction.MANAGE_ACCESS) setEditedDomain(row) - }} /> - ) - }] : []), + }} + onManageSettings={() => { + setEditAction(MailDomainEditAction.MANAGE_SETTINGS) + setEditedDomain(row) + }} + /> + ), + }, ]; return ( @@ -86,6 +101,26 @@ function AdminDataGrid({ domains, pagination }: AdminDataGridProps) { }} /> )} + {editedDomain && editAction === MailDomainEditAction.MANAGE_SETTINGS && (() => { + // Get fresh domain data from the list + const freshDomain = domains.find(d => d.id === editedDomain.id) || editedDomain; + return ( + { + setEditedDomain(null) + setEditAction(null) + }} + onSuccess={() => { + refetchMaildomains(); + setEditedDomain(null) + setEditAction(null) + }} + /> + ); + })()} ); } @@ -161,16 +196,32 @@ export default function AdminPage() { ); } -const ActionsCell = ({ domain, onManageAccess }: { domain: MailDomainAdmin, onManageAccess: () => void }) => { +const ActionsCell = ({ domain, onManageAccess, onManageSettings }: { domain: MailDomainAdmin, onManageAccess: () => void, onManageSettings: () => void }) => { const { t } = useTranslation(); const canManageAccesses = useAbility(Abilities.CAN_MANAGE_MAILDOMAIN_ACCESSES, domain); - if (!canManageAccesses) return null; + const canManageSettings = useAbility(Abilities.CAN_MANAGE_MAILDOMAIN_SETTINGS, domain); + + if (!canManageAccesses && !canManageSettings) return null; return ( - +
+ {canManageAccesses && ( + + )} + {canManageSettings && ( + + )} +
) }