Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions env.d/development/backend.e2e
Original file line number Diff line number Diff line change
Expand Up @@ -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
314 changes: 295 additions & 19 deletions src/backend/core/api/openapi.json

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions src/backend/core/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
128 changes: 124 additions & 4 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -216,10 +217,18 @@ class MailboxSerializer(AbilitiesModelSerializer):
role = serializers.SerializerMethodField(read_only=True)
count_unread_messages = serializers.SerializerMethodField(read_only=True)
count_messages = serializers.SerializerMethodField(read_only=True)
max_recipients_per_message = serializers.SerializerMethodField(read_only=True)

class Meta:
model = models.Mailbox
fields = ["id", "email", "role", "count_unread_messages", "count_messages"]
fields = [
"id",
"email",
"role",
"count_unread_messages",
"count_messages",
"max_recipients_per_message",
]

def get_email(self, instance):
"""Return the email of the mailbox."""
Expand Down Expand Up @@ -280,6 +289,11 @@ def get_abilities(self, instance):
"""Get abilities for the instance."""
return super().get_abilities(instance)

@extend_schema_field(serializers.IntegerField())
def get_max_recipients_per_message(self, instance):
"""Return the maximum number of recipients per message for the mailbox."""
return instance.get_max_recipients_per_message()


class MailboxLightSerializer(serializers.ModelSerializer):
"""Serializer for mailbox details in thread access."""
Expand Down Expand Up @@ -857,6 +871,10 @@ class MailDomainAdminSerializer(AbilitiesModelSerializer):
"""Serialize mail domains for admin view."""

expected_dns_records = serializers.SerializerMethodField(read_only=True)
custom_limits = serializers.SerializerMethodField(
read_only=True,
help_text="Limits applied to this mail domain (e.g. max recipients per message).",
)

def get_expected_dns_records(self, instance):
"""Return the expected DNS records for the mail domain, only in detail views."""
Expand All @@ -868,9 +886,33 @@ def get_expected_dns_records(self, instance):

return None

@extend_schema_field(
{
"type": "object",
"properties": {
"max_recipients_per_message": {
"type": "integer",
"nullable": True,
"description": "Maximum number of recipients per message for this domain.",
}
},
"nullable": True,
}
)
def get_custom_limits(self, instance):
"""Return custom_limits with proper structure."""
return instance.custom_limits or None

class Meta:
model = models.MailDomain
fields = ["id", "name", "created_at", "updated_at", "expected_dns_records"]
fields = [
"id",
"name",
"created_at",
"updated_at",
"expected_dns_records",
"custom_limits",
]
read_only_fields = fields

@extend_schema_field(
Expand Down Expand Up @@ -937,7 +979,7 @@ def validate(self, attrs):


class MailDomainAdminWriteSerializer(serializers.ModelSerializer):
"""Serialize mail domains for creating / editing admin view."""
"""Serialize mail domains for creating admin view."""

class Meta:
model = models.MailDomain
Expand All @@ -953,6 +995,53 @@ class Meta:
read_only_fields = ["id", "created_at", "updated_at"]


class CustomLimitsUpdateMixin:
"""Mixin for validating custom_limits field."""

def validate_custom_limits(self, value):
"""Validate that custom_limits does not exceed global maximum."""
if value and isinstance(value, dict):
max_recipients = value.get("max_recipients_per_message")
if max_recipients is not None and isinstance(max_recipients, int):
if max_recipients <= 0:
raise serializers.ValidationError(
_("The limit must be greater than 0.")
)
if max_recipients > settings.MAX_RECIPIENTS_PER_MESSAGE:
raise serializers.ValidationError(
_(
"The limit cannot exceed the global maximum of %(max)s recipients."
)
% {"max": settings.MAX_RECIPIENTS_PER_MESSAGE}
)
return value


class MailDomainAdminUpdateSerializer(
CustomLimitsUpdateMixin, serializers.ModelSerializer
):
"""Serialize mail domains for updating custom_limits only."""

class Meta:
model = models.MailDomain
fields = [
"id",
"custom_limits",
]
read_only_fields = ["id"]


class MailboxSettingsUpdateSerializer(
CustomLimitsUpdateMixin, serializers.ModelSerializer
):
"""Serialize mailbox settings (custom_limits) for update operations."""

class Meta:
model = models.Mailbox
fields = ["id", "custom_limits"]
read_only_fields = ["id"]


class MailboxAccessNestedUserSerializer(serializers.ModelSerializer):
"""
Serialize MailboxAccess for nesting within MailboxAdminSerializer.
Expand Down Expand Up @@ -982,6 +1071,27 @@ class MailboxAdminSerializer(serializers.ModelSerializer):
alias_of = serializers.PrimaryKeyRelatedField(
required=False, allow_null=True, queryset=models.Mailbox.objects.none()
)
custom_limits = serializers.SerializerMethodField(
read_only=True,
help_text="Limits applied to this mailbox (e.g. max recipients per message).",
)

@extend_schema_field(
{
"type": "object",
"properties": {
"max_recipients_per_message": {
"type": "integer",
"nullable": True,
"description": "Maximum number of recipients per message for this mailbox.",
}
},
"nullable": True,
}
)
def get_custom_limits(self, instance):
"""Return custom_limits with proper structure."""
return instance.custom_limits or None

class Meta:
model = models.Mailbox
Expand All @@ -996,6 +1106,7 @@ class Meta:
"updated_at",
"can_reset_password",
"contact",
"custom_limits",
]
read_only_fields = [
"id",
Expand All @@ -1006,6 +1117,7 @@ class Meta:
"updated_at",
"can_reset_password",
"contact",
"custom_limits", # Read-only here, use /settings/ endpoint to update
]

def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -1152,6 +1264,9 @@ class MailboxAdminCreateSerializer(MailboxAdminSerializer):
"""
Serialize Mailbox details for create admin endpoint, including users with access and
metadata.

Note: custom_limits is excluded from the response as it cannot be set during creation.
It can only be modified via the dedicated /settings/ endpoint.
"""

one_time_password = serializers.SerializerMethodField(
Expand All @@ -1165,7 +1280,12 @@ def get_one_time_password(self, instance) -> str | None:

class Meta:
model = models.Mailbox
fields = MailboxAdminSerializer.Meta.fields + ["one_time_password"]
# Exclude custom_limits from creation response - it can only be set via /settings/
fields = [
field
for field in MailboxAdminSerializer.Meta.fields
if field != "custom_limits"
] + ["one_time_password"]
read_only_fields = fields


Expand Down
36 changes: 34 additions & 2 deletions src/backend/core/api/viewsets/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,7 +85,28 @@ class ConfigView(drf.views.APIView):
},
"MAX_INCOMING_EMAIL_SIZE": {
"type": "integer",
"description": "Maximum size in bytes for incoming email (including attachments and body)",
"description": (
"Maximum size in bytes for incoming email "
"(including attachments and body)"
),
"readOnly": True,
},
"MAX_RECIPIENTS_PER_MESSAGE": {
"type": "integer",
"description": (
"Maximum number of recipients per message "
"(to + cc + bcc) for the entire system. "
"Cannot be exceeded."
),
"readOnly": True,
},
"MAX_DEFAULT_RECIPIENTS_PER_MESSAGE": {
"type": "integer",
"description": (
"Default maximum number of recipients per message "
"(to + cc + bcc) for a mailbox or maildomain "
"if no custom limit is set."
),
"readOnly": True,
},
},
Expand All @@ -98,6 +122,8 @@ class ConfigView(drf.views.APIView):
"MAX_OUTGOING_ATTACHMENT_SIZE",
"MAX_OUTGOING_BODY_SIZE",
"MAX_INCOMING_EMAIL_SIZE",
"MAX_RECIPIENTS_PER_MESSAGE",
"MAX_DEFAULT_RECIPIENTS_PER_MESSAGE",
],
},
)
Expand Down Expand Up @@ -132,6 +158,12 @@ def get(self, request):
)
dict_settings["MAX_OUTGOING_BODY_SIZE"] = settings.MAX_OUTGOING_BODY_SIZE
dict_settings["MAX_INCOMING_EMAIL_SIZE"] = settings.MAX_INCOMING_EMAIL_SIZE
dict_settings["MAX_RECIPIENTS_PER_MESSAGE"] = (
settings.MAX_RECIPIENTS_PER_MESSAGE
)
dict_settings["MAX_DEFAULT_RECIPIENTS_PER_MESSAGE"] = (
settings.MAX_DEFAULT_RECIPIENTS_PER_MESSAGE
)

# Drive service
if base_url := settings.DRIVE_CONFIG.get("base_url"):
Expand Down
14 changes: 11 additions & 3 deletions src/backend/core/api/viewsets/mailbox.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""API ViewSet for Mailbox model."""

from django.db.models import OuterRef, Q, Subquery
from django.db.models import Exists, OuterRef, Q, Subquery

from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
from rest_framework import mixins, viewsets
Expand All @@ -24,7 +24,7 @@ class MailboxViewSet(
def get_queryset(self):
"""Restrict results to the current user's mailboxes."""
user = self.request.user
# For regular users, annotate with their actual role
# For regular users, annotate with their actual role and domain admin status
return (
models.Mailbox.objects.filter(accesses__user=user)
.prefetch_related("accesses__user", "domain")
Expand All @@ -33,7 +33,15 @@ def get_queryset(self):
models.MailboxAccess.objects.filter(
mailbox=OuterRef("pk"), user=user
).values("role")[:1]
)
),
# Annotate domain admin status to avoid N+1 queries in get_abilities()
is_domain_admin=Exists(
models.MailDomainAccess.objects.filter(
user=user,
maildomain=OuterRef("domain"),
role=models.MailDomainAccessRoleChoices.ADMIN,
)
),
)
.order_by("-created_at")
)
Expand Down
Loading