Skip to content

Commit b2d5de7

Browse files
committed
✨(recipients) handle MAX_RECIPIENTS_PER_MESSAGE
Define global env var to configure max recipients allowed per message Implement frontend restrictions Add custom_limits on mailbox and maildomain Add playwright tests
1 parent 3b6f0b9 commit b2d5de7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2451
-80
lines changed

src/backend/core/api/openapi.json

Lines changed: 272 additions & 7 deletions
Large diffs are not rendered by default.

src/backend/core/api/permissions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,33 @@ def has_permission(self, request, view):
450450
return models.MailboxAccess.objects.filter(
451451
user=request.user, mailbox=view.kwargs.get("mailbox_id")
452452
).exists()
453+
454+
455+
class CanManageSettings(permissions.BasePermission):
456+
"""
457+
Permission class that checks if the user has CAN_MANAGE_SETTINGS ability
458+
for the given MailDomain or Mailbox object.
459+
460+
This permission should be used for actions that modify custom_limits and other settings.
461+
"""
462+
463+
message = "You do not have permission to manage settings for this resource."
464+
465+
def has_object_permission(self, request, view, obj):
466+
"""
467+
Check if user has CAN_MANAGE_SETTINGS ability for the object.
468+
Works with both MailDomain and Mailbox objects.
469+
"""
470+
if not request.user or not request.user.is_authenticated:
471+
return False
472+
473+
# Get abilities for this object
474+
abilities = obj.get_abilities(request.user)
475+
476+
# Check for the specific ability
477+
if isinstance(obj, models.MailDomain):
478+
return abilities[enums.MailDomainAbilities.CAN_MANAGE_SETTINGS]
479+
if isinstance(obj, models.Mailbox):
480+
return abilities[enums.MailboxAbilities.CAN_MANAGE_SETTINGS]
481+
482+
return False

src/backend/core/api/serializers.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66

7+
from django.conf import settings
78
from django.db import transaction
89
from django.db.models import Count, Exists, OuterRef, Q
910
from django.utils.translation import gettext_lazy as _
@@ -216,10 +217,18 @@ class MailboxSerializer(AbilitiesModelSerializer):
216217
role = serializers.SerializerMethodField(read_only=True)
217218
count_unread_messages = serializers.SerializerMethodField(read_only=True)
218219
count_messages = serializers.SerializerMethodField(read_only=True)
220+
max_recipients_per_message = serializers.SerializerMethodField(read_only=True)
219221

220222
class Meta:
221223
model = models.Mailbox
222-
fields = ["id", "email", "role", "count_unread_messages", "count_messages"]
224+
fields = [
225+
"id",
226+
"email",
227+
"role",
228+
"count_unread_messages",
229+
"count_messages",
230+
"max_recipients_per_message",
231+
]
223232

224233
def get_email(self, instance):
225234
"""Return the email of the mailbox."""
@@ -280,6 +289,10 @@ def get_abilities(self, instance):
280289
"""Get abilities for the instance."""
281290
return super().get_abilities(instance)
282291

292+
def get_max_recipients_per_message(self, instance):
293+
"""Return the maximum number of recipients per message for the mailbox."""
294+
return instance.get_max_recipients_per_message()
295+
283296

284297
class MailboxLightSerializer(serializers.ModelSerializer):
285298
"""Serializer for mailbox details in thread access."""
@@ -870,7 +883,14 @@ def get_expected_dns_records(self, instance):
870883

871884
class Meta:
872885
model = models.MailDomain
873-
fields = ["id", "name", "created_at", "updated_at", "expected_dns_records"]
886+
fields = [
887+
"id",
888+
"name",
889+
"created_at",
890+
"updated_at",
891+
"expected_dns_records",
892+
"custom_limits",
893+
]
874894
read_only_fields = fields
875895

876896
@extend_schema_field(
@@ -937,7 +957,7 @@ def validate(self, attrs):
937957

938958

939959
class MailDomainAdminWriteSerializer(serializers.ModelSerializer):
940-
"""Serialize mail domains for creating / editing admin view."""
960+
"""Serialize mail domains for creating admin view."""
941961

942962
class Meta:
943963
model = models.MailDomain
@@ -953,6 +973,49 @@ class Meta:
953973
read_only_fields = ["id", "created_at", "updated_at"]
954974

955975

976+
class CustomLimitsUpdateMixin:
977+
"""Mixin for validating custom_limits field."""
978+
979+
def validate_custom_limits(self, value):
980+
"""Validate that custom_limits does not exceed global maximum."""
981+
if value and isinstance(value, dict):
982+
max_recipients = value.get("max_recipients_per_message")
983+
if max_recipients is not None and isinstance(max_recipients, int):
984+
if max_recipients > settings.MAX_RECIPIENTS_PER_MESSAGE:
985+
raise serializers.ValidationError(
986+
_(
987+
"The limit cannot exceed the global maximum of %(max)s recipients."
988+
)
989+
% {"max": settings.MAX_RECIPIENTS_PER_MESSAGE}
990+
)
991+
return value
992+
993+
994+
class MailDomainAdminUpdateSerializer(
995+
CustomLimitsUpdateMixin, serializers.ModelSerializer
996+
):
997+
"""Serialize mail domains for updating custom_limits only."""
998+
999+
class Meta:
1000+
model = models.MailDomain
1001+
fields = [
1002+
"id",
1003+
"custom_limits",
1004+
]
1005+
read_only_fields = ["id"]
1006+
1007+
1008+
class MailboxSettingsUpdateSerializer(
1009+
CustomLimitsUpdateMixin, serializers.ModelSerializer
1010+
):
1011+
"""Serialize mailbox settings (custom_limits) for update operations."""
1012+
1013+
class Meta:
1014+
model = models.Mailbox
1015+
fields = ["id", "custom_limits"]
1016+
read_only_fields = ["id"]
1017+
1018+
9561019
class MailboxAccessNestedUserSerializer(serializers.ModelSerializer):
9571020
"""
9581021
Serialize MailboxAccess for nesting within MailboxAdminSerializer.
@@ -996,6 +1059,7 @@ class Meta:
9961059
"updated_at",
9971060
"can_reset_password",
9981061
"contact",
1062+
"custom_limits",
9991063
]
10001064
read_only_fields = [
10011065
"id",
@@ -1152,6 +1216,9 @@ class MailboxAdminCreateSerializer(MailboxAdminSerializer):
11521216
"""
11531217
Serialize Mailbox details for create admin endpoint, including users with access and
11541218
metadata.
1219+
1220+
Note: custom_limits is excluded from the response as it cannot be set during creation.
1221+
It can only be modified via the dedicated /settings/ endpoint.
11551222
"""
11561223

11571224
one_time_password = serializers.SerializerMethodField(
@@ -1165,7 +1232,12 @@ def get_one_time_password(self, instance) -> str | None:
11651232

11661233
class Meta:
11671234
model = models.Mailbox
1168-
fields = MailboxAdminSerializer.Meta.fields + ["one_time_password"]
1235+
# Exclude custom_limits from creation response - it can only be set via /settings/
1236+
fields = [
1237+
field
1238+
for field in MailboxAdminSerializer.Meta.fields
1239+
if field != "custom_limits"
1240+
] + ["one_time_password"]
11691241
read_only_fields = fields
11701242

11711243

src/backend/core/api/viewsets/config.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from django.conf import settings
44

55
import rest_framework as drf
6-
from drf_spectacular.utils import OpenApiResponse, extend_schema
6+
from drf_spectacular.utils import (
7+
OpenApiResponse,
8+
extend_schema,
9+
)
710
from rest_framework.permissions import AllowAny
811

912
from core.ai.utils import is_ai_enabled, is_ai_summary_enabled, is_auto_labels_enabled
@@ -82,7 +85,28 @@ class ConfigView(drf.views.APIView):
8285
},
8386
"MAX_INCOMING_EMAIL_SIZE": {
8487
"type": "integer",
85-
"description": "Maximum size in bytes for incoming email (including attachments and body)",
88+
"description": (
89+
"Maximum size in bytes for incoming email "
90+
"(including attachments and body)"
91+
),
92+
"readOnly": True,
93+
},
94+
"MAX_RECIPIENTS_PER_MESSAGE": {
95+
"type": "integer",
96+
"description": (
97+
"Maximum number of recipients per message "
98+
"(to + cc + bcc) for the entire system. "
99+
"Cannot be exceeded."
100+
),
101+
"readOnly": True,
102+
},
103+
"MAX_DEFAULT_RECIPIENTS_PER_MESSAGE": {
104+
"type": "integer",
105+
"description": (
106+
"Default maximum number of recipients per message "
107+
"(to + cc + bcc) for a mailbox or maildomain "
108+
"if no custom limit is set."
109+
),
86110
"readOnly": True,
87111
},
88112
},
@@ -98,6 +122,8 @@ class ConfigView(drf.views.APIView):
98122
"MAX_OUTGOING_ATTACHMENT_SIZE",
99123
"MAX_OUTGOING_BODY_SIZE",
100124
"MAX_INCOMING_EMAIL_SIZE",
125+
"MAX_RECIPIENTS_PER_MESSAGE",
126+
"MAX_DEFAULT_RECIPIENTS_PER_MESSAGE",
101127
],
102128
},
103129
)
@@ -132,6 +158,12 @@ def get(self, request):
132158
)
133159
dict_settings["MAX_OUTGOING_BODY_SIZE"] = settings.MAX_OUTGOING_BODY_SIZE
134160
dict_settings["MAX_INCOMING_EMAIL_SIZE"] = settings.MAX_INCOMING_EMAIL_SIZE
161+
dict_settings["MAX_RECIPIENTS_PER_MESSAGE"] = (
162+
settings.MAX_RECIPIENTS_PER_MESSAGE
163+
)
164+
dict_settings["MAX_DEFAULT_RECIPIENTS_PER_MESSAGE"] = (
165+
settings.MAX_DEFAULT_RECIPIENTS_PER_MESSAGE
166+
)
135167

136168
# Drive service
137169
if base_url := settings.DRIVE_CONFIG.get("base_url"):

src/backend/core/api/viewsets/maildomain.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class AdminMailDomainViewSet(
3838
mixins.ListModelMixin,
3939
mixins.RetrieveModelMixin,
4040
mixins.CreateModelMixin,
41+
mixins.UpdateModelMixin,
4142
viewsets.GenericViewSet,
4243
):
4344
"""
@@ -55,12 +56,16 @@ class AdminMailDomainViewSet(
5556
def get_permissions(self):
5657
if self.action == "create":
5758
return [core_permissions.IsSuperUser()]
59+
if self.action in ["update", "partial_update"]:
60+
return [core_permissions.CanManageSettings()]
5861
return super().get_permissions()
5962

6063
def get_serializer_class(self):
6164
"""Select serializer based on action."""
6265
if self.action == "create":
6366
return core_serializers.MailDomainAdminWriteSerializer
67+
if self.action in ["update", "partial_update"]:
68+
return core_serializers.MailDomainAdminUpdateSerializer
6469
return super().get_serializer_class()
6570

6671
def get_queryset(self):
@@ -159,6 +164,21 @@ class AdminMailDomainMailboxViewSet(
159164
]
160165
serializer_class = core_serializers.MailboxAdminSerializer
161166

167+
def get_serializer_class(self):
168+
"""Select serializer based on action."""
169+
if self.action == "update_settings":
170+
return core_serializers.MailboxSettingsUpdateSerializer
171+
return super().get_serializer_class()
172+
173+
def get_permissions(self):
174+
"""Override permissions for specific actions."""
175+
if self.action == "update_settings":
176+
# Domain admin + ability to manage settings
177+
return [permission() for permission in self.permission_classes] + [
178+
core_permissions.CanManageSettings()
179+
]
180+
return super().get_permissions()
181+
162182
def get_queryset(self):
163183
maildomain_pk = self.kwargs.get("maildomain_pk")
164184
return models.Mailbox.objects.filter(domain_id=maildomain_pk)
@@ -238,6 +258,7 @@ def create(self, request, *args, **kwargs):
238258
required=False, allow_blank=True
239259
),
240260
"custom_attributes": drf_serializers.JSONField(required=False),
261+
"custom_limits": drf_serializers.JSONField(required=False),
241262
},
242263
),
243264
},
@@ -332,6 +353,38 @@ def reset_password(self, request, *args, **kwargs):
332353
{"one_time_password": mailbox_password}, status=status.HTTP_200_OK
333354
)
334355

356+
@extend_schema(
357+
operation_id="maildomains_mailboxes_settings_update",
358+
description="Update mailbox settings (custom_limits).",
359+
request=core_serializers.MailboxSettingsUpdateSerializer,
360+
responses={
361+
200: OpenApiResponse(
362+
response=core_serializers.MailboxSettingsUpdateSerializer,
363+
description="Mailbox settings updated successfully.",
364+
),
365+
400: OpenApiResponse(
366+
description="Invalid settings data.",
367+
),
368+
403: OpenApiResponse(
369+
description="User does not have permission to manage settings.",
370+
),
371+
},
372+
)
373+
@action(detail=True, methods=["patch"], url_path="settings")
374+
def update_settings(self, request, maildomain_pk=None, pk=None):
375+
"""
376+
Update mailbox settings (custom_limits).
377+
378+
Only domain administrators can manage mailbox settings.
379+
This endpoint is separate from the general mailbox update endpoint
380+
to enforce proper permission checks at the viewset level.
381+
"""
382+
mailbox = self.get_object()
383+
serializer = self.get_serializer(mailbox, data=request.data, partial=True)
384+
serializer.is_valid(raise_exception=True)
385+
serializer.save()
386+
return Response(serializer.data, status=status.HTTP_200_OK)
387+
335388

336389
class AdminMailDomainMessageTemplateViewSet(
337390
mixins.CreateModelMixin,

src/backend/core/enums.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class MailDomainAbilities(models.TextChoices):
9898

9999
CAN_MANAGE_ACCESSES = "manage_accesses", "Can manage accesses"
100100
CAN_MANAGE_MAILBOXES = "manage_mailboxes", "Can manage mailboxes"
101+
CAN_MANAGE_SETTINGS = "manage_settings", "Can manage settings"
101102

102103

103104
class MailboxAbilities(models.TextChoices):
@@ -112,6 +113,7 @@ class MailboxAbilities(models.TextChoices):
112113
"Can manage mailbox message templates",
113114
)
114115
CAN_IMPORT_MESSAGES = "import_messages", "Can import messages"
116+
CAN_MANAGE_SETTINGS = "manage_settings", "Can manage settings"
115117

116118

117119
class MessageTemplateTypeChoices(models.IntegerChoices):

0 commit comments

Comments
 (0)