diff --git a/compose.yaml b/compose.yaml index 1b8db83a2..8a7e5a876 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,6 +19,28 @@ services: ports: - "8913:6379" + redis-quotas: + image: redis:7-alpine + command: > + redis-server + --port 6380 + --maxmemory-policy noeviction + --appendonly yes + --appendfsync everysec + --save 900 1 + --save 300 10 + --save 60 10000 + ports: + - "8916:6380" + volumes: + - redis-quotas-data:/data + healthcheck: + test: ["CMD", "redis-cli", "-p", "6380", "ping"] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + opensearch: image: opensearchproject/opensearch:2.19.2 environment: @@ -355,3 +377,7 @@ services: - "8902:8802" depends_on: - postgresql + +volumes: + redis-quotas-data: + driver: local 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..ff2f6113d 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -222,6 +222,36 @@ "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 + }, + "MAX_RECIPIENTS_FOR_DOMAIN": { + "type": "string", + "description": "Maximum recipients per period for a domain (format: 'number/period', e.g., '1500/d' for 1500 per day). Cannot be exceeded.", + "readOnly": true + }, + "MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN": { + "type": "string", + "description": "Default maximum recipients per period for a domain (format: 'number/period', e.g., '1000/d' for 1000 per day) if no custom limit is set.", + "readOnly": true + }, + "MAX_RECIPIENTS_FOR_MAILBOX": { + "type": "string", + "description": "Maximum recipients per period for a mailbox (format: 'number/period', e.g., '500/d' for 500 per day). Cannot be exceeded.", + "readOnly": true + }, + "MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX": { + "type": "string", + "description": "Default maximum recipients per period for a mailbox (format: 'number/period', e.g., '100/d' for 100 per day) if no custom limit is set.", + "readOnly": true } }, "required": [ @@ -235,7 +265,13 @@ "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", + "MAX_RECIPIENTS_FOR_DOMAIN", + "MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN", + "MAX_RECIPIENTS_FOR_MAILBOX", + "MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX" ] } } @@ -2868,6 +2904,42 @@ } } }, + "/api/v1.0/mailboxes/{id}/quota/": { + "get": { + "operationId": "mailboxes_quota_retrieve", + "description": "Get the recipient quota status for this mailbox.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "mailboxes" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipientQuota" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/mailboxes/{id}/search/": { "get": { "operationId": "mailboxes_search_list", @@ -3027,6 +3099,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 +3515,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3388,9 +3558,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3447,9 +3615,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3478,6 +3644,51 @@ } } }, + "/api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/quota/": { + "get": { + "operationId": "maildomains_mailboxes_quota_retrieve", + "description": "Get the recipient quota status for this mailbox.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "maildomain_pk", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "maildomains" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipientQuota" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/reset-password/": { "patch": { "operationId": "maildomains_mailboxes_reset_password", @@ -3487,9 +3698,7 @@ "in": "path", "name": "id", "schema": { - "type": "string", - "format": "uuid", - "description": "primary key for the record as UUID" + "type": "string" }, "required": true }, @@ -3555,6 +3764,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", @@ -3860,6 +4134,44 @@ } } }, + "/api/v1.0/maildomains/{maildomain_pk}/quota/": { + "get": { + "operationId": "maildomains_quota_retrieve", + "description": "Get the recipient quota status for this mail domain.", + "parameters": [ + { + "in": "path", + "name": "maildomain_pk", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + } + ], + "tags": [ + "maildomains" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipientQuota" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/messages/": { "get": { "operationId": "messages_list", @@ -5542,6 +5854,24 @@ "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." + }, + "max_recipients": { + "type": "string", + "nullable": true, + "description": "Maximum recipients per period (format: 'number/period', e.g., '500/d' for 500 per day)." + } + }, + "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 +5903,10 @@ "manage_mailboxes": { "type": "boolean", "description": "Can manage mailboxes" + }, + "manage_settings": { + "type": "boolean", + "description": "Can manage settings" } }, "required": [ @@ -5582,7 +5916,8 @@ "patch", "delete", "manage_accesses", - "manage_mailboxes" + "manage_mailboxes", + "manage_settings" ], "readOnly": true } @@ -5590,15 +5925,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 +6009,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 +6063,10 @@ "type": "string", "readOnly": true }, + "max_recipients_per_message": { + "type": "integer", + "readOnly": true + }, "abilities": { "type": "object", "description": "Instance permissions and capabilities", @@ -5747,6 +6114,10 @@ "import_messages": { "type": "boolean", "description": "Can import messages" + }, + "manage_settings": { + "type": "boolean", + "description": "Can manage settings" } }, "required": [ @@ -5760,7 +6131,8 @@ "send_messages", "manage_labels", "manage_message_templates", - "import_messages" + "import_messages", + "manage_settings" ], "readOnly": true } @@ -5771,6 +6143,7 @@ "count_unread_messages", "email", "id", + "max_recipients_per_message", "role" ] }, @@ -5977,6 +6350,24 @@ } ], "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." + }, + "max_recipients": { + "type": "string", + "nullable": true, + "description": "Maximum recipients per period (format: 'number/period', e.g., '500/d' for 500 per day)." + } + }, + "nullable": true, + "readOnly": true, + "description": "Limits applied to this mailbox (e.g. max recipients per message)." } }, "required": [ @@ -5984,6 +6375,7 @@ "can_reset_password", "contact", "created_at", + "custom_limits", "domain_name", "id", "is_identity", @@ -5993,7 +6385,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 +6515,8 @@ "name": { "type": "string" }, - "custom_attributes": {} + "custom_attributes": {}, + "custom_limits": {} } }, "MailboxLight": { @@ -6160,6 +6553,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 +7272,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 +7303,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.", @@ -7010,6 +7439,51 @@ "updated_at" ] }, + "RecipientQuota": { + "type": "object", + "description": "Serializer for recipient quota status.", + "properties": { + "period": { + "type": "string", + "description": "The quota period type (d=day, m=month, y=year)" + }, + "period_display": { + "type": "string", + "description": "Human-readable period name" + }, + "period_start": { + "type": "string", + "format": "date-time", + "description": "Start of the current quota period" + }, + "recipient_count": { + "type": "integer", + "description": "Number of recipients sent during this period" + }, + "quota_limit": { + "type": "integer", + "description": "Maximum number of recipients allowed during this period" + }, + "remaining": { + "type": "integer", + "description": "Number of remaining recipients that can be sent to" + }, + "usage_percentage": { + "type": "number", + "format": "double", + "description": "Percentage of quota used (0-100)" + } + }, + "required": [ + "period", + "period_display", + "period_start", + "quota_limit", + "recipient_count", + "remaining", + "usage_percentage" + ] + }, "ResetPasswordError": { "type": "object", "properties": { 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..21e47b901 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 _ @@ -13,6 +14,7 @@ from rest_framework.exceptions import PermissionDenied from core import enums, models +from core.models import parse_max_recipients class IntegerChoicesField(serializers.ChoiceField): @@ -216,10 +218,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 +290,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 +872,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 +887,41 @@ 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.", + }, + "max_recipients": { + "type": "string", + "nullable": True, + "description": ( + "Maximum recipients per period " + "(format: 'number/period', e.g., '500/d')" + ), + }, + }, + "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 +988,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 +1004,87 @@ 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): + # Validate max_recipients_per_message + max_recipients_per_msg = value.get("max_recipients_per_message") + if max_recipients_per_msg is not None and isinstance( + max_recipients_per_msg, int + ): + if max_recipients_per_msg <= 0: + raise serializers.ValidationError( + _("The limit must be greater than 0.") + ) + if max_recipients_per_msg > 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} + ) + + # Validate max_recipients (format: "number/period" like "500/d") + max_recipients = value.get("max_recipients") + if max_recipients is not None: + try: + limit, period = parse_max_recipients(max_recipients) + + # Determine which max to use based on model type + # For mailboxes, use MAX_RECIPIENTS_FOR_MAILBOX + # For domains, use MAX_RECIPIENTS_FOR_DOMAIN + if self.Meta.model == models.Mailbox: + max_setting = settings.MAX_RECIPIENTS_FOR_MAILBOX + else: + max_setting = settings.MAX_RECIPIENTS_FOR_DOMAIN + + global_limit, global_period = parse_max_recipients(max_setting) + + # Only compare if periods match + if period == global_period and limit > global_limit: + raise serializers.ValidationError( + _("The limit cannot exceed the global maximum of %(max)s.") + % {"max": max_setting} + ) + + # Note: With Redis-based quotas, mailbox and domain quotas are + # independent. Domain capacity constraint is enforced at send time, + # not at configuration time. + + except ValueError as e: + raise serializers.ValidationError(str(e)) from e + + 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 +1114,35 @@ 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.", + }, + "max_recipients": { + "type": "string", + "nullable": True, + "description": ( + "Maximum recipients per period " + "(format: 'number/period', e.g., '500/d')" + ), + }, + }, + "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 +1157,7 @@ class Meta: "updated_at", "can_reset_password", "contact", + "custom_limits", ] read_only_fields = [ "id", @@ -1006,6 +1168,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 +1315,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 +1331,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 @@ -1492,3 +1663,39 @@ def create(self, validated_data): def update(self, instance, validated_data): """This serializer is only used to validate the data, not to create or update.""" + + +class RecipientQuotaSerializer(serializers.Serializer): + """Serializer for recipient quota status (read-only).""" + + period = serializers.CharField( + help_text="The quota period type (d=day, m=month)" + ) + period_display = serializers.CharField(help_text="Human-readable period name") + period_start = serializers.DateTimeField( + help_text="Start of the current quota period" + ) + recipient_count = serializers.IntegerField( + help_text="Number of recipients sent during this period" + ) + quota_limit = serializers.IntegerField( + help_text="Maximum number of recipients allowed during this period" + ) + remaining = serializers.IntegerField( + help_text="Number of remaining recipients that can be sent to" + ) + usage_percentage = serializers.FloatField( + help_text="Percentage of quota used (0-100)" + ) + + class Meta: + fields = [ + "period", + "period_display", + "period_start", + "recipient_count", + "quota_limit", + "remaining", + "usage_percentage", + ] + read_only_fields = fields diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index 3cc938c23..7bf145cdd 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,64 @@ 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, + }, + "MAX_RECIPIENTS_FOR_DOMAIN": { + "type": "string", + "description": ( + "Maximum recipients per period for a domain " + "(format: 'number/period', e.g., '1500/d' for 1500 per day). " + "Cannot be exceeded." + ), + "readOnly": True, + }, + "MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN": { + "type": "string", + "description": ( + "Default maximum recipients per period for a domain " + "(format: 'number/period', e.g., '1000/d' for 1000 per day) " + "if no custom limit is set." + ), + "readOnly": True, + }, + "MAX_RECIPIENTS_FOR_MAILBOX": { + "type": "string", + "description": ( + "Maximum recipients per period for a mailbox " + "(format: 'number/period', e.g., '500/d' for 500 per day). " + "Cannot be exceeded." + ), + "readOnly": True, + }, + "MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX": { + "type": "string", + "description": ( + "Default maximum recipients per period for a mailbox " + "(format: 'number/period', e.g., '100/d' for 100 per day) " + "if no custom limit is set." + ), "readOnly": True, }, }, @@ -98,6 +158,12 @@ 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", + "MAX_RECIPIENTS_FOR_DOMAIN", + "MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN", + "MAX_RECIPIENTS_FOR_MAILBOX", + "MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX", ], }, ) @@ -132,6 +198,22 @@ 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 + ) + dict_settings["MAX_RECIPIENTS_FOR_DOMAIN"] = settings.MAX_RECIPIENTS_FOR_DOMAIN + dict_settings["MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN"] = ( + settings.MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN + ) + dict_settings["MAX_RECIPIENTS_FOR_MAILBOX"] = ( + settings.MAX_RECIPIENTS_FOR_MAILBOX + ) + dict_settings["MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX"] = ( + settings.MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX + ) # 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..b9aa40063 100644 --- a/src/backend/core/api/viewsets/mailbox.py +++ b/src/backend/core/api/viewsets/mailbox.py @@ -1,6 +1,7 @@ """API ViewSet for Mailbox model.""" -from django.db.models import OuterRef, Q, Subquery +from django.db.models import Exists, OuterRef, Q, Subquery +from django.utils import timezone from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema from rest_framework import mixins, viewsets @@ -8,6 +9,7 @@ from rest_framework.response import Response from core import models +from core.services.quota import quota_service from .. import permissions, serializers @@ -24,7 +26,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 +35,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") ) @@ -77,3 +87,60 @@ def search(self, request, **kwargs): serializer = serializers.MailboxLightSerializer(queryset, many=True) return Response(serializer.data) + + @extend_schema( + tags=["mailboxes"], + responses=serializers.RecipientQuotaSerializer(), + description="Get the recipient quota status for this mailbox.", + ) + @action(detail=True, methods=["get"]) + def quota(self, request, **kwargs): + """ + Get the recipient quota status for a mailbox. + + Returns the current quota usage including: + - period: The quota period type (d/m/y) + - period_display: Human-readable period name + - period_start: Start of the current quota period + - recipient_count: Number of recipients sent during this period + - quota_limit: Maximum allowed recipients + - remaining: Number of recipients still available + - usage_percentage: Percentage of quota used + """ + mailbox = self.get_object() + + # Get the effective max_recipients for this mailbox + quota_limit, period = mailbox.get_max_recipients() + + # Get quota status from Redis + status_data = quota_service.get_status( + entity_type="mailbox", + entity_id=str(mailbox.id), + period=period, + limit=quota_limit, + ) + + # Calculate period_start based on period type + now = timezone.now() + if period == "d": + period_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif period == "m": + period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + elif period == "y": + period_start = now.replace( + month=1, day=1, hour=0, minute=0, second=0, microsecond=0 + ) + else: + period_start = now + + period_display = {"d": "day", "m": "month", "y": "year"}.get(period, period) + + data = { + "period": period, + "period_display": period_display, + "period_start": period_start, + **status_data, # recipient_count, quota_limit, remaining, usage_percentage + } + + serializer = serializers.RecipientQuotaSerializer(data) + return Response(serializer.data) diff --git a/src/backend/core/api/viewsets/maildomain.py b/src/backend/core/api/viewsets/maildomain.py index 3f57ecdbb..fc4a079f5 100644 --- a/src/backend/core/api/viewsets/maildomain.py +++ b/src/backend/core/api/viewsets/maildomain.py @@ -4,8 +4,9 @@ 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 django.utils import timezone from drf_spectacular.utils import ( OpenApiParameter, @@ -30,6 +31,7 @@ from core.api import serializers as core_serializers from core.enums import MessageTemplateTypeChoices from core.services.dns.check import check_dns_records +from core.services.quota import quota_service logger = getLogger(__name__) @@ -38,6 +40,7 @@ class AdminMailDomainViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, + mixins.UpdateModelMixin, viewsets.GenericViewSet, ): """ @@ -55,12 +58,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): @@ -136,6 +143,63 @@ def check_dns(self, request, maildomain_pk=None): } ) + @extend_schema( + tags=["maildomains"], + responses=core_serializers.RecipientQuotaSerializer(), + description="Get the recipient quota status for this mail domain.", + ) + @action(detail=True, methods=["get"], url_path="quota") + def quota(self, request, maildomain_pk=None): + """ + Get the recipient quota status for a mail domain. + + Returns the current quota usage including: + - period: The quota period type (d/m/y) + - period_display: Human-readable period name + - period_start: Start of the current quota period + - recipient_count: Number of recipients sent during this period + - quota_limit: Maximum allowed recipients + - remaining: Number of recipients still available + - usage_percentage: Percentage of quota used + """ + maildomain = get_object_or_404(models.MailDomain, pk=maildomain_pk) + + # Get the effective max_recipients for this domain + quota_limit, period = maildomain.get_max_recipients() + + # Get quota status from Redis + status_data = quota_service.get_status( + entity_type="domain", + entity_id=str(maildomain.id), + period=period, + limit=quota_limit, + ) + + # Calculate period_start based on period type + now = timezone.now() + if period == "d": + period_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif period == "m": + period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + elif period == "y": + period_start = now.replace( + month=1, day=1, hour=0, minute=0, second=0, microsecond=0 + ) + else: + period_start = now + + period_display = {"d": "day", "m": "month", "y": "year"}.get(period, period) + + data = { + "period": period, + "period_display": period_display, + "period_start": period_start, + **status_data, # recipient_count, quota_limit, remaining, usage_percentage + } + + serializer = core_serializers.RecipientQuotaSerializer(data) + return Response(serializer.data) + class AdminMailDomainMailboxViewSet( mixins.CreateModelMixin, @@ -159,9 +223,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 +333,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 +428,95 @@ 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): # pylint: disable=unused-argument + """ + 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) + + @extend_schema( + tags=["maildomains"], + responses=core_serializers.RecipientQuotaSerializer(), + description="Get the recipient quota status for this mailbox.", + ) + @action(detail=True, methods=["get"], url_path="quota") + def quota(self, request, maildomain_pk=None, pk=None): # pylint: disable=unused-argument + """ + Get the recipient quota status for a mailbox. + + Returns the current quota usage including: + - period: The quota period type (d/m/y) + - period_display: Human-readable period name + - period_start: Start of the current quota period + - recipient_count: Number of recipients sent during this period + - quota_limit: Maximum allowed recipients + - remaining: Number of recipients still available + - usage_percentage: Percentage of quota used + """ + mailbox = self.get_object() + + # Get the effective max_recipients for this mailbox + quota_limit, period = mailbox.get_max_recipients() + + # Get quota status from Redis + status_data = quota_service.get_status( + entity_type="mailbox", + entity_id=str(mailbox.id), + period=period, + limit=quota_limit, + ) + + # Calculate period_start based on period type + now = timezone.now() + if period == "d": + period_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif period == "m": + period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + elif period == "y": + period_start = now.replace( + month=1, day=1, hour=0, minute=0, second=0, microsecond=0 + ) + else: + period_start = now + + period_display = {"d": "day", "m": "month", "y": "year"}.get(period, period) + + data = { + "period": period, + "period_display": period_display, + "period_start": period_start, + **status_data, # recipient_count, quota_limit, remaining, usage_percentage + } + + serializer = core_serializers.RecipientQuotaSerializer(data) + return Response(serializer.data) + class AdminMailDomainMessageTemplateViewSet( mixins.CreateModelMixin, diff --git a/src/backend/core/api/viewsets/send.py b/src/backend/core/api/viewsets/send.py index 55e98e645..6863047ab 100644 --- a/src/backend/core/api/viewsets/send.py +++ b/src/backend/core/api/viewsets/send.py @@ -2,6 +2,8 @@ import logging +from django.utils.translation import gettext_lazy as _ + from drf_spectacular.utils import ( OpenApiExample, extend_schema, @@ -16,12 +18,79 @@ from core import models from core.mda.outbound import prepare_outbound_message from core.mda.tasks import send_message_task +from core.services.quota import quota_service from .. import permissions, serializers logger = logging.getLogger(__name__) +def check_and_update_recipient_quota( + mailbox: models.Mailbox, recipient_count: int +) -> None: + """ + Check if the mailbox and its domain can send to the specified number of recipients + and update both quotas if allowed using Redis. + + This implementation uses a single atomic Lua script to check and increment both + mailbox and domain quotas simultaneously, avoiding the need for rollback logic. + + Args: + mailbox: The sender mailbox + recipient_count: Number of recipients in the message + + Raises: + drf_exceptions.PermissionDenied: If either quota would be exceeded + """ + # Get quota limits + mailbox_limit, mailbox_period = mailbox.get_max_recipients() + domain_limit, domain_period = mailbox.domain.get_max_recipients() + + # Atomic check and increment for mailbox and domain quotas + success, failed_entity, remaining = quota_service.check_and_increment( + mailbox_id=str(mailbox.id), + mailbox_period=mailbox_period, + mailbox_limit=mailbox_limit, + domain_id=str(mailbox.domain.id), + domain_period=domain_period, + domain_limit=domain_limit, + recipient_count=recipient_count, + ) + + if not success: + if failed_entity == "mailbox": + period_display = {"d": "day", "m": "month", "y": "year"}.get( + mailbox_period, mailbox_period + ) + raise drf_exceptions.PermissionDenied( + _( + "Recipient quota exceeded. You can send to %(remaining)s more recipients " + "this %(period)s. Attempted to send to %(count)s recipients." + ) + % { + "remaining": remaining, + "period": period_display, + "count": recipient_count, + } + ) + + # Domain quota exceeded + period_display = {"d": "day", "m": "month", "y": "year"}.get( + domain_period, domain_period + ) + raise drf_exceptions.PermissionDenied( + _( + "Domain recipient quota exceeded. The domain can send to %(remaining)s more " + "recipients this %(period)s. Attempted to send to %(count)s recipients." + ) + % { + "remaining": remaining, + "period": period_display, + "count": recipient_count, + } + ) + + @extend_schema( tags=["messages"], request=serializers.SendMessageSerializer, @@ -106,6 +175,10 @@ def post(self, request): self.check_object_permissions(request, message) + # Check recipient quota before sending + recipient_count = message.recipients.count() + check_and_update_recipient_quota(mailbox_sender, recipient_count) + prepared = prepare_outbound_message( mailbox_sender, message, diff --git a/src/backend/core/apps.py b/src/backend/core/apps.py index b92ecf1d7..db5783fa3 100644 --- a/src/backend/core/apps.py +++ b/src/backend/core/apps.py @@ -17,6 +17,9 @@ def ready(self): from django.conf import settings + # Import checks to register them + from . import checks # noqa: F401 + if settings.ENABLE_PROMETHEUS: from prometheus_client.core import REGISTRY diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index b159746b1..ab934b541 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): @@ -121,6 +123,13 @@ class MessageTemplateTypeChoices(models.IntegerChoices): SIGNATURE = 2, "signature" +class QuotaPeriodChoices(models.TextChoices): + """Defines the possible quota period types.""" + + DAY = "d", "day" + MONTH = "m", "month" + + EML_SUPPORTED_MIME_TYPES = ["message/rfc822", "application/eml", "text/plain"] MBOX_SUPPORTED_MIME_TYPES = [ "application/octet-stream", diff --git a/src/backend/core/management/commands/redis_quotas_info.py b/src/backend/core/management/commands/redis_quotas_info.py new file mode 100644 index 000000000..7a5512e8f --- /dev/null +++ b/src/backend/core/management/commands/redis_quotas_info.py @@ -0,0 +1,90 @@ +"""Management command to display Redis quotas metrics.""" + +from django.core.management.base import BaseCommand + +from core.services.quota import quota_service + + +class Command(BaseCommand): + """Display Redis quotas metrics and health information.""" + + help = "Display Redis quotas metrics" + + def handle(self, *args, **options): + """Execute the command.""" + redis = quota_service.redis + + self.stdout.write(self.style.SUCCESS("=== Redis Quotas Info ===\n")) + + # Server info + try: + info = redis.info("server") + self.stdout.write(f"Version: {info['redis_version']}") + self.stdout.write(f"Uptime: {info['uptime_in_days']} days\n") + except Exception as e: + self.stdout.write(self.style.ERROR(f"Failed to get server info: {e}")) + return + + # Memory + try: + memory = redis.info("memory") + used_mb = memory["used_memory"] / 1024 / 1024 + peak_mb = memory["used_memory_peak"] / 1024 / 1024 + self.stdout.write(f"Memory: {used_mb:.2f} MB (peak: {peak_mb:.2f} MB)") + except Exception as e: + self.stdout.write(self.style.WARNING(f"Could not get memory info: {e}")) + + # Persistence + try: + persistence = redis.info("persistence") + self.stdout.write(f"AOF enabled: {persistence['aof_enabled']}") + if persistence["aof_enabled"]: + aof_size_mb = persistence["aof_current_size"] / 1024 / 1024 + self.stdout.write(f"AOF size: {aof_size_mb:.2f} MB") + except Exception as e: + self.stdout.write( + self.style.WARNING(f"Could not get persistence info: {e}") + ) + + # Keyspace + try: + keyspace = redis.info("keyspace") + db_info = keyspace.get("db0", {}) + total_keys = db_info.get("keys", 0) + self.stdout.write(f"\nTotal keys: {total_keys}") + except Exception as e: + self.stdout.write(self.style.WARNING(f"Could not get keyspace info: {e}")) + + # Quota keys breakdown + patterns = [ + ("Mailbox quotas (daily)", "quota:mailbox:*:d:*"), + ("Mailbox quotas (monthly)", "quota:mailbox:*:m:*"), + ("Mailbox quotas (yearly)", "quota:mailbox:*:y:*"), + ("Domain quotas (daily)", "quota:domain:*:d:*"), + ("Domain quotas (monthly)", "quota:domain:*:m:*"), + ("Domain quotas (yearly)", "quota:domain:*:y:*"), + ] + + self.stdout.write("\n--- Quota Breakdown ---") + try: + for label, pattern in patterns: + # Use scan_iter with count limit to avoid blocking + keys = list(redis.scan_iter(pattern, count=1000)) + if keys: + self.stdout.write(f"{label}: {len(keys)}") + except Exception as e: + self.stdout.write(self.style.WARNING(f"Could not scan keys: {e}")) + + # Connection test + self.stdout.write("\n--- Connection Test ---") + try: + test_key = "health:test:cmd" + redis.set(test_key, "ok", ex=10) + value = redis.get(test_key) + redis.delete(test_key) + if value == "ok": + self.stdout.write(self.style.SUCCESS("✓ Connection working")) + else: + self.stdout.write(self.style.ERROR("✗ Connection test failed")) + except Exception as e: + self.stdout.write(self.style.ERROR(f"✗ Connection error: {e}")) 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..27a530e2e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -39,6 +39,7 @@ MessageDeliveryStatusChoices, MessageRecipientTypeChoices, MessageTemplateTypeChoices, + QuotaPeriodChoices, ThreadAccessRoleChoices, UserAbilities, ) @@ -48,6 +49,50 @@ logger = getLogger(__name__) +def parse_max_recipients(value: str) -> tuple[int, str]: + """ + Parse a max_recipients value in the format "number/period". + + Args: + value: String like "500/d", "1000/m" + + Returns: + Tuple of (limit, period) where period is 'd' or 'm' + + Raises: + ValueError: If the format is invalid + """ + if not value or not isinstance(value, str): + raise ValueError("max_recipients must be a non-empty string") + + parts = value.strip().split("/") + if len(parts) != 2: + raise ValueError( + "max_recipients must be in format 'number/period' (e.g., '500/d')" + ) + + try: + limit = int(parts[0]) + except ValueError: + raise ValueError("max_recipients limit must be a valid integer") from None + + if limit < 1: + raise ValueError("max_recipients limit must be a positive integer") + + period = parts[1].lower() + if period not in ( + QuotaPeriodChoices.DAY, + QuotaPeriodChoices.MONTH, + ): + raise ValueError( + f"max_recipients period must be one of: " + f"'{QuotaPeriodChoices.DAY}' (day), " + f"'{QuotaPeriodChoices.MONTH}' (month)" + ) + + return limit, period + + class DuplicateEmailError(Exception): """Raised when an email is already associated with a pre-existing user.""" @@ -279,6 +324,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 +347,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 @@ -304,6 +358,30 @@ def clean(self): {"custom_attributes": exception.message} ) from exception + # 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"} + ) + # Validate max_recipients_per_message + 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" + } + ) + # Validate max_recipients (format: "number/period" like "500/d") + if "max_recipients" in self.custom_limits: + value = self.custom_limits["max_recipients"] + if value is not None: + try: + parse_max_recipients(value) + except ValueError as e: + raise ValidationError({"custom_limits": str(e)}) from e + super().clean() def get_expected_dns_records(self) -> List[str]: @@ -375,6 +453,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( @@ -414,6 +494,41 @@ def get_active_dkim_key(self): ).first() # Most recent due to ordering in model ) + def get_max_recipients(self) -> tuple[int, str]: + """Return the effective max recipients per period for a mail domain. + + Priority: + 1. MailDomain custom_limits.max_recipients + 2. Global setting MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN + 3. Capped by MAX_RECIPIENTS_FOR_DOMAIN (cannot be exceeded) + + Returns: + Tuple of (limit, period) where period is 'd', 'm', or 'y' + """ + # Parse global settings for domain + domain_max_limit, domain_max_period = parse_max_recipients( + settings.MAX_RECIPIENTS_FOR_DOMAIN + ) + domain_default_limit, domain_default_period = parse_max_recipients( + settings.MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN + ) + + # MailDomain-level override + if hasattr(self, "custom_limits") and self.custom_limits: + value = self.custom_limits.get("max_recipients") + if value: + try: + limit, period = parse_max_recipients(value) + if period == domain_max_period: + return min(limit, domain_max_limit), period + return limit, period + except ValueError: + pass # Invalid format, fall through to global + + # Global fallback - ensure it never exceeds the domain maximum + effective_limit = min(domain_default_limit, domain_max_limit) + return effective_limit, domain_default_period + class Channel(BaseModel): """Channel model to store channel information for receiving messages from various sources.""" @@ -497,6 +612,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 +631,39 @@ 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"} + ) + # Validate max_recipients_per_message + 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" + } + ) + # Validate max_recipients (format: "number/period" like "500/d") + if "max_recipients" in self.custom_limits: + value = self.custom_limits["max_recipients"] + if value is not None: + try: + parse_max_recipients(value) + except ValueError as e: + raise ValidationError({"custom_limits": str(e)}) from e + + super().clean() + @property def can_reset_password(self) -> bool: """Return True if the mailbox user's password can be reset.""" @@ -577,12 +733,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 +766,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 +781,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 +849,84 @@ 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, + ) + + def get_max_recipients(self) -> tuple[int, str]: + """Return the effective max recipients per period for a mailbox. + + Priority: + 1. Mailbox custom_limits.max_recipients + 2. Global setting MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX + 3. Capped by MAX_RECIPIENTS_FOR_MAILBOX (cannot be exceeded) + + Note: Domain's max_recipients is NOT inherited - it's for the aggregate + domain quota. Both mailbox and domain quotas are enforced independently + at send time via Redis. + + Returns: + Tuple of (limit, period) where period is 'd', 'm', or 'y' + """ + # Parse global settings for mailbox + mailbox_max_limit, mailbox_max_period = parse_max_recipients( + settings.MAX_RECIPIENTS_FOR_MAILBOX + ) + mailbox_default_limit, mailbox_default_period = parse_max_recipients( + settings.MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX + ) + + # Mailbox-level override + if hasattr(self, "custom_limits") and self.custom_limits: + value = self.custom_limits.get("max_recipients") + if value: + try: + limit, period = parse_max_recipients(value) + # Period must match mailbox max period + if period == mailbox_max_period: + return min(limit, mailbox_max_limit), period + # If periods differ, use the mailbox value but log a warning + logger.warning( + "Mailbox %s has max_recipients period '%s' different from global '%s'", + str(self), + period, + mailbox_max_period, + ) + return limit, period + except ValueError: + pass # Invalid format, fall through to global + + # Global fallback for mailbox - ensure it never exceeds the mailbox maximum + effective_limit = min(mailbox_default_limit, mailbox_max_limit) + return effective_limit, mailbox_default_period + class MailboxAccess(BaseModel): """Mailbox access model to store mailbox access information.""" diff --git a/src/backend/core/services/quota.py b/src/backend/core/services/quota.py new file mode 100644 index 000000000..2953c05e0 --- /dev/null +++ b/src/backend/core/services/quota.py @@ -0,0 +1,278 @@ +""" +Recipient quota service. + +This service provides high-performance quota tracking using Redis with automatic +TTL-based period resets. Quotas are independent (mailbox and domain quotas are +checked separately without dynamic capacity constraints). +""" + +from typing import Literal + +from django.utils import timezone + +EntityType = Literal["mailbox", "domain"] +PeriodType = Literal["d", "m"] + + +class RecipientQuotaRedisService: + """Service for managing recipient quotas in Redis.""" + + def __init__(self): + """Initialize service (Redis connection is lazy-loaded).""" + self._redis = None + + @property + def redis(self): + """Lazy-load Redis connection to dedicated quotas instance.""" + if self._redis is None: + from django.conf import settings + + import redis + + self._redis = redis.Redis( + host=settings.REDIS_QUOTAS_HOST, + port=settings.REDIS_QUOTAS_PORT, + db=settings.REDIS_QUOTAS_DB, + password=settings.REDIS_QUOTAS_PASSWORD, + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2, + retry_on_timeout=True, + health_check_interval=30, # Check connection health every 30s + ) + + return self._redis + + def _get_key( + self, entity_type: EntityType, entity_id: str, period: PeriodType + ) -> str: + """ + Generate Redis key for a quota. + + Format: quota:{entity_type}:{entity_id}:{period}:{period_key} + Example: quota:mailbox:abc-123:d:2025-12-11 + """ + period_key = self._get_period_key(period) + return f"quota:{entity_type}:{entity_id}:{period}:{period_key}" + + def _get_period_key(self, period: PeriodType) -> str: + """ + Get the period-specific key suffix. + + Returns: + - "d" → "2025-12-11" (daily) + - "m" → "2025-12" (monthly) + """ + now = timezone.now() + if period == "d": + return now.strftime("%Y-%m-%d") + elif period == "m": + return now.strftime("%Y-%m") + raise ValueError(f"Invalid period: {period}") + + def _get_ttl(self, period: PeriodType) -> int: + """ + Get TTL in seconds for the period. + + Adds a small buffer to ensure the key expires after the period ends. + """ + if period == "d": + # Expire at end of day + 1 hour buffer + now = timezone.now() + end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=0) + return int((end_of_day - now).total_seconds()) + 3600 + elif period == "m": + # 32 days = longest month (31 days) + 1 day safety buffer + return 32 * 24 * 3600 + raise ValueError(f"Invalid period: {period}") + + def get_count( + self, entity_type: EntityType, entity_id: str, period: PeriodType + ) -> int: + """ + Get current recipient count for an entity. + + Args: + entity_type: "mailbox" or "domain" + entity_id: UUID of the entity + period: "d" or "m" + + Returns: + Current recipient count (0 if key doesn't exist) + """ + key = self._get_key(entity_type, entity_id, period) + value = self.redis.get(key) + return int(value) if value else 0 + + def increment( + self, + entity_type: EntityType, + entity_id: str, + period: PeriodType, + count: int, + ) -> int: + """ + Atomically increment the recipient count. + + Args: + entity_type: "mailbox" or "domain" + entity_id: UUID of the entity + period: "d" or "m" + count: Number to increment by (can be negative for rollback) + + Returns: + New total count after increment + """ + key = self._get_key(entity_type, entity_id, period) + ttl = self._get_ttl(period) + + pipe = self.redis.pipeline() + pipe.incrby(key, count) + pipe.expire(key, ttl) + results = pipe.execute() + return results[0] # New count after increment + + def check_and_increment( + self, + mailbox_id: str, + mailbox_period: PeriodType, + mailbox_limit: int, + domain_id: str, + domain_period: PeriodType, + domain_limit: int, + recipient_count: int, + ) -> tuple[bool, str, int]: + """ + Atomically check and increment mailbox and domain quotas. + + Uses a single Lua script to check both quotas and increment them + atomically, avoiding race conditions and rollback logic. + + Args: + mailbox_id: UUID of the mailbox + mailbox_period: Period for mailbox ("d" or "m") + mailbox_limit: Mailbox quota limit + domain_id: UUID of the domain + domain_period: Period for domain ("d" or "m") + domain_limit: Domain quota limit + recipient_count: Number of recipients to add + + Returns: + Tuple of (success, failed_entity, remaining) + - success: True if both quotas allowed and incremented + - failed_entity: "mailbox" or "domain" if one failed, "" if success + - remaining: Remaining capacity of the entity that failed + """ + mailbox_key = self._get_key("mailbox", mailbox_id, mailbox_period) + domain_key = self._get_key("domain", domain_id, domain_period) + mailbox_ttl = self._get_ttl(mailbox_period) + domain_ttl = self._get_ttl(domain_period) + + # Lua script for atomic check-and-increment of BOTH quotas + # Returns: {success (0/1), failed_entity (0=none, 1=mailbox, 2=domain), remaining} + lua_script = """ + local mailbox_key = KEYS[1] + local domain_key = KEYS[2] + local mailbox_limit = tonumber(ARGV[1]) + local domain_limit = tonumber(ARGV[2]) + local increment = tonumber(ARGV[3]) + local mailbox_ttl = tonumber(ARGV[4]) + local domain_ttl = tonumber(ARGV[5]) + + -- Get current values + local mailbox_current = tonumber(redis.call('GET', mailbox_key) or '0') + local domain_current = tonumber(redis.call('GET', domain_key) or '0') + + -- Check mailbox quota first + if mailbox_current + increment > mailbox_limit then + return {0, 1, mailbox_limit - mailbox_current} -- Mailbox failed + end + + -- Check domain quota + if domain_current + increment > domain_limit then + return {0, 2, domain_limit - domain_current} -- Domain failed + end + + -- Both OK: increment both atomically + redis.call('INCRBY', mailbox_key, increment) + redis.call('EXPIRE', mailbox_key, mailbox_ttl) + redis.call('INCRBY', domain_key, increment) + redis.call('EXPIRE', domain_key, domain_ttl) + + return {1, 0, 0} -- Success + """ + + result = self.redis.eval( + lua_script, + 2, # 2 keys + mailbox_key, + domain_key, + mailbox_limit, + domain_limit, + recipient_count, + mailbox_ttl, + domain_ttl, + ) + + success = bool(result[0]) + failed_code = int(result[1]) + remaining = int(result[2]) + + failed_entity_map = {0: "", 1: "mailbox", 2: "domain"} + failed_entity = failed_entity_map.get(failed_code, "") + + return success, failed_entity, remaining + + def reset( + self, entity_type: EntityType, entity_id: str, period: PeriodType + ) -> None: + """ + Manually reset a quota (delete the key). + + Normally not needed as TTL handles expiry automatically. + + Args: + entity_type: "mailbox" or "domain" + entity_id: UUID of the entity + period: "d" or "m" + """ + key = self._get_key(entity_type, entity_id, period) + self.redis.delete(key) + + def get_status( + self, + entity_type: EntityType, + entity_id: str, + period: PeriodType, + limit: int, + ) -> dict: + """ + Get quota status with usage statistics. + + Args: + entity_type: "mailbox" or "domain" + entity_id: UUID of the entity + period: "d" or "m" + limit: Maximum allowed recipients for this period + + Returns: + Dictionary with: + - recipient_count: Current count + - quota_limit: Maximum limit + - remaining: Recipients remaining + - usage_percentage: Percentage used (0-100) + """ + current_count = self.get_count(entity_type, entity_id, period) + remaining = max(0, limit - current_count) + usage_percentage = int((current_count / limit) * 100) if limit > 0 else 0 + + return { + "recipient_count": current_count, + "quota_limit": limit, + "remaining": remaining, + "usage_percentage": min(100, usage_percentage), # Cap at 100% + } + + +# Singleton instance +quota_service = RecipientQuotaRedisService() 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..b03ef75a6 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -249,6 +249,50 @@ 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 + ) + + # Maximum recipients per period for a domain (cannot be exceeded) + MAX_RECIPIENTS_FOR_DOMAIN = values.Value( + "1500/d", environ_name="MAX_RECIPIENTS_FOR_DOMAIN", environ_prefix=None + ) + # Default maximum recipients per period for a domain if no custom limit is set + MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN = values.Value( + "1000/d", environ_name="MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN", environ_prefix=None + ) + # Maximum recipients per period for a mailbox (cannot be exceeded) + MAX_RECIPIENTS_FOR_MAILBOX = values.Value( + "500/d", environ_name="MAX_RECIPIENTS_FOR_MAILBOX", environ_prefix=None + ) + # Default maximum recipients per period for a mailbox if no custom limit is set + MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX = values.Value( + "100/d", environ_name="MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX", environ_prefix=None + ) + + # Redis dedicated to quotas (separate from cache) + REDIS_QUOTAS_HOST = values.Value( + "redis-quotas", environ_name="REDIS_QUOTAS_HOST", environ_prefix=None + ) + REDIS_QUOTAS_PORT = values.PositiveIntegerValue( + 6380, environ_name="REDIS_QUOTAS_PORT", environ_prefix=None + ) + REDIS_QUOTAS_DB = values.PositiveIntegerValue( + 0, environ_name="REDIS_QUOTAS_DB", environ_prefix=None + ) + REDIS_QUOTAS_PASSWORD = values.Value( + None, environ_name="REDIS_QUOTAS_PASSWORD", 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/mailboxes/mailboxes.ts b/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts index 33face908..4c15c00d4 100644 --- a/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts +++ b/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts @@ -33,6 +33,7 @@ import type { MessageTemplateRequest, PatchedMessageTemplateRequest, ReadOnlyMessageTemplate, + RecipientQuota, } from ".././models"; import { fetchAPI } from "../../fetch-api"; @@ -1679,6 +1680,190 @@ export function useMailboxesRetrieve< return query; } +/** + * Get the recipient quota status for this mailbox. + */ +export type mailboxesQuotaRetrieveResponse200 = { + data: RecipientQuota; + status: 200; +}; + +export type mailboxesQuotaRetrieveResponseComposite = + mailboxesQuotaRetrieveResponse200; + +export type mailboxesQuotaRetrieveResponse = + mailboxesQuotaRetrieveResponseComposite & { + headers: Headers; + }; + +export const getMailboxesQuotaRetrieveUrl = (id: string) => { + return `/api/v1.0/mailboxes/${id}/quota/`; +}; + +export const mailboxesQuotaRetrieve = async ( + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesQuotaRetrieveUrl(id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMailboxesQuotaRetrieveQueryKey = (id: string) => { + return [`/api/v1.0/mailboxes/${id}/quota/`] as const; +}; + +export const getMailboxesQuotaRetrieveQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getMailboxesQuotaRetrieveQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => mailboxesQuotaRetrieve(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MailboxesQuotaRetrieveQueryResult = NonNullable< + Awaited> +>; +export type MailboxesQuotaRetrieveQueryError = unknown; + +export function useMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMailboxesQuotaRetrieveQueryOptions(id, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + /** * Search mailboxes by domain, local part and contact name. diff --git a/src/frontend/src/features/api/gen/maildomains/maildomains.ts b/src/frontend/src/features/api/gen/maildomains/maildomains.ts index 085e8ae4f..cd70b65ea 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,8 +39,11 @@ import type { MessageTemplateRequest, PaginatedMailDomainAdminList, PaginatedMailboxAdminList, + PatchedMailDomainAdminUpdateRequest, PatchedMailboxAdminPartialUpdatePayloadRequest, + PatchedMailboxSettingsUpdateRequest, PatchedMessageTemplateRequest, + RecipientQuota, ResetPasswordError, ResetPasswordInternalServerError, ResetPasswordNotFound, @@ -522,6 +528,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. */ @@ -1410,8 +1624,215 @@ export const useMaildomainsMailboxesDestroy = < const mutationOptions = getMaildomainsMailboxesDestroyMutationOptions(options); - return useMutation(mutationOptions, queryClient); -}; + return useMutation(mutationOptions, queryClient); +}; +/** + * Get the recipient quota status for this mailbox. + */ +export type maildomainsMailboxesQuotaRetrieveResponse200 = { + data: RecipientQuota; + status: 200; +}; + +export type maildomainsMailboxesQuotaRetrieveResponseComposite = + maildomainsMailboxesQuotaRetrieveResponse200; + +export type maildomainsMailboxesQuotaRetrieveResponse = + maildomainsMailboxesQuotaRetrieveResponseComposite & { + headers: Headers; + }; + +export const getMaildomainsMailboxesQuotaRetrieveUrl = ( + maildomainPk: string, + id: string, +) => { + return `/api/v1.0/maildomains/${maildomainPk}/mailboxes/${id}/quota/`; +}; + +export const maildomainsMailboxesQuotaRetrieve = async ( + maildomainPk: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMaildomainsMailboxesQuotaRetrieveUrl(maildomainPk, id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMaildomainsMailboxesQuotaRetrieveQueryKey = ( + maildomainPk: string, + id: string, +) => { + return [ + `/api/v1.0/maildomains/${maildomainPk}/mailboxes/${id}/quota/`, + ] as const; +}; + +export const getMaildomainsMailboxesQuotaRetrieveQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getMaildomainsMailboxesQuotaRetrieveQueryKey(maildomainPk, id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + maildomainsMailboxesQuotaRetrieve(maildomainPk, id, { + signal, + ...requestOptions, + }); + + return { + queryKey, + queryFn, + enabled: !!(maildomainPk && id), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MaildomainsMailboxesQuotaRetrieveQueryResult = NonNullable< + Awaited> +>; +export type MaildomainsMailboxesQuotaRetrieveQueryError = unknown; + +export function useMaildomainsMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMaildomainsMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMaildomainsMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMaildomainsMailboxesQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMaildomainsMailboxesQuotaRetrieveQueryOptions( + maildomainPk, + id, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + /** * Reset the Keycloak password for a specific mailbox. */ @@ -1545,6 +1966,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. */ @@ -2421,3 +2983,190 @@ export const useMaildomainsMessageTemplatesDestroy = < return useMutation(mutationOptions, queryClient); }; +/** + * Get the recipient quota status for this mail domain. + */ +export type maildomainsQuotaRetrieveResponse200 = { + data: RecipientQuota; + status: 200; +}; + +export type maildomainsQuotaRetrieveResponseComposite = + maildomainsQuotaRetrieveResponse200; + +export type maildomainsQuotaRetrieveResponse = + maildomainsQuotaRetrieveResponseComposite & { + headers: Headers; + }; + +export const getMaildomainsQuotaRetrieveUrl = (maildomainPk: string) => { + return `/api/v1.0/maildomains/${maildomainPk}/quota/`; +}; + +export const maildomainsQuotaRetrieve = async ( + maildomainPk: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMaildomainsQuotaRetrieveUrl(maildomainPk), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMaildomainsQuotaRetrieveQueryKey = (maildomainPk: string) => { + return [`/api/v1.0/maildomains/${maildomainPk}/quota/`] as const; +}; + +export const getMaildomainsQuotaRetrieveQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getMaildomainsQuotaRetrieveQueryKey(maildomainPk); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + maildomainsQuotaRetrieve(maildomainPk, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!maildomainPk, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MaildomainsQuotaRetrieveQueryResult = NonNullable< + Awaited> +>; +export type MaildomainsQuotaRetrieveQueryError = unknown; + +export function useMaildomainsQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMaildomainsQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMaildomainsQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMaildomainsQuotaRetrieve< + TData = Awaited>, + TError = unknown, +>( + maildomainPk: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMaildomainsQuotaRetrieveQueryOptions( + maildomainPk, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} 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..0dc05d673 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,16 @@ 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; + /** Maximum recipients per period for a domain (format: 'number/period', e.g., '1500/d' for 1500 per day). Cannot be exceeded. */ + readonly MAX_RECIPIENTS_FOR_DOMAIN: string; + /** Default maximum recipients per period for a domain (format: 'number/period', e.g., '1000/d' for 1000 per day) if no custom limit is set. */ + readonly MAX_DEFAULT_RECIPIENTS_FOR_DOMAIN: string; + /** Maximum recipients per period for a mailbox (format: 'number/period', e.g., '500/d' for 500 per day). Cannot be exceeded. */ + readonly MAX_RECIPIENTS_FOR_MAILBOX: string; + /** Default maximum recipients per period for a mailbox (format: 'number/period', e.g., '100/d' for 100 per day) if no custom limit is set. */ + readonly MAX_DEFAULT_RECIPIENTS_FOR_MAILBOX: string; }; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index a0a5ef0ec..8b562e651 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,12 +104,15 @@ 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"; export * from "./read_only_message_template"; +export * from "./recipient_quota"; export * from "./reset_password_error"; export * from "./reset_password_internal_server_error"; export * from "./reset_password_not_found"; 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..e2453412e --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mail_domain_admin_custom_limits.ts @@ -0,0 +1,24 @@ +/** + * 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; + /** + * Maximum recipients per period (format: 'number/period', e.g., '500/d' for 500 per day). + * @nullable + */ + readonly max_recipients?: string | 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..54cc785d9 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_admin_custom_limits.ts @@ -0,0 +1,24 @@ +/** + * 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; + /** + * Maximum recipients per period (format: 'number/period', e.g., '500/d' for 500 per day). + * @nullable + */ + readonly max_recipients?: string | 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/api/gen/models/recipient_quota.ts b/src/frontend/src/features/api/gen/models/recipient_quota.ts new file mode 100644 index 000000000..19c30aebf --- /dev/null +++ b/src/frontend/src/features/api/gen/models/recipient_quota.ts @@ -0,0 +1,27 @@ +/** + * 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) + */ + +/** + * Serializer for recipient quota status. + */ +export interface RecipientQuota { + /** The quota period type (d=day, m=month, y=year) */ + period: string; + /** Human-readable period name */ + period_display: string; + /** Start of the current quota period */ + period_start: string; + /** Number of recipients sent during this period */ + recipient_count: number; + /** Maximum number of recipients allowed during this period */ + quota_limit: number; + /** Number of remaining recipients that can be sent to */ + remaining: number; + /** Percentage of quota used (0-100) */ + usage_percentage: number; +} diff --git a/src/frontend/src/features/forms/components/message-form/_index.scss b/src/frontend/src/features/forms/components/message-form/_index.scss index ca300a1ee..867549694 100644 --- a/src/frontend/src/features/forms/components/message-form/_index.scss +++ b/src/frontend/src/features/forms/components/message-form/_index.scss @@ -170,3 +170,30 @@ cursor: wait; pointer-events: auto; } + +// Recipient quota display +.recipient-quota-display { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + padding: 2px 8px; + border-radius: 4px; + font-size: var(--c--theme--font--sizes--sm); + color: var(--c--theme--colors--greyscale-600); + background-color: var(--c--theme--colors--greyscale-100); + + .material-icons { + font-size: 1rem; + } +} + +.recipient-quota-display--warning { + color: var(--c--theme--colors--warning-800); + background-color: var(--c--theme--colors--warning-100); +} + +.recipient-quota-display--critical { + color: var(--c--theme--colors--danger-800); + background-color: var(--c--theme--colors--danger-100); +} 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..effe3dda0 100644 --- a/src/frontend/src/features/forms/components/message-form/index.tsx +++ b/src/frontend/src/features/forms/components/message-form/index.tsx @@ -1,4 +1,4 @@ -import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit"; +import { Icon, IconSize, IconType, Spinner } from "@gouvfr-lasuite/ui-kit"; import { Button, Tooltip } from "@openfun/cunningham-react"; import { clsx } from "clsx"; import { useEffect, useMemo, useState, useRef } from "react"; @@ -6,7 +6,9 @@ import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import z from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { Attachment, DraftMessageRequestRequest, Message, sendCreateResponse200, useDraftCreate, useDraftUpdate2, useMessagesDestroy, useSendCreate } from "@/features/api/gen"; +import { fetchAPI } from "@/features/api/fetch-api"; import { MessageComposer, QuoteType } from "@/features/forms/components/message-composer"; import { useMailboxContext } from "@/features/providers/mailbox"; import MailHelper from "@/features/utils/mail-helper"; @@ -25,6 +27,86 @@ 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"; + +type RecipientQuota = { + period: string; + period_display: string; + period_start: string; + recipient_count: number; + quota_limit: number; + remaining: number; + usage_percentage: number; +}; + +/** + * Component to display recipient quota status + */ +const RecipientQuotaDisplay = ({ mailboxId }: { mailboxId: string }) => { + const { t } = useTranslation(); + const { data: quota, isLoading, isError, error } = useQuery({ + queryKey: ['mailbox-quota', mailboxId], + queryFn: async () => { + try { + // Note: URL without trailing slash to match DRF action pattern + const response = await fetchAPI<{ data: RecipientQuota }>(`/api/v1.0/mailboxes/${mailboxId}/quota/`); + console.log('Quota response:', response); + return response.data; + } catch (err) { + console.error('Failed to fetch recipient quota:', err); + throw err; + } + }, + enabled: !!mailboxId, + refetchInterval: 60000, // Refresh every minute + retry: 1, // Only retry once on error + }); + + // Log error for debugging + if (isError) { + console.error('Recipient quota query error:', error); + } + + // Don't show anything if loading, error, or no data + if (isLoading || isError || !quota) return null; + + const isLow = quota.usage_percentage >= 70; + const isCritical = quota.usage_percentage >= 90; + + const periodLabels: Record = { + 'd': t('today'), + 'm': t('this month'), + 'y': t('this year'), + }; + + const periodLabel = periodLabels[quota.period] || quota.period_display; + + return ( +
+ + + {t("{{remaining}} recipients remaining {{period}}", { + remaining: quota.remaining, + period: periodLabel, + })} + +
+ ); +}; export type MessageFormMode = "new" | "reply" | "reply_all" | "forward"; @@ -53,7 +135,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 +147,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 +193,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 +226,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 +306,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 +557,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 +594,7 @@ export const MessageForm = ({ form.setError("to", { message: t("At least one recipient is required.") }); return; } + if (!draft || !canSendMessages) return; messageMutation.mutate({ @@ -531,6 +694,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 +711,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 +727,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] })) : []} @@ -620,6 +786,7 @@ export const MessageForm = ({ t("Last saved {{relativeTime}}", { relativeTime: DateHelper.formatRelativeTime(draft.updated_at, currentTime) }) ) } + {currentSenderId && }