Skip to content

Conversation

@sdemagny
Copy link
Contributor

@sdemagny sdemagny commented Dec 15, 2025

Define global env var to configure max recipients allowed per message
Implement frontend restrictions
Add custom_limits on mailbox and maildomain
Add playwright tests

Summary by CodeRabbit

  • New Features

    • Configurable recipient limits: global defaults plus per-domain and per-mailbox custom_limits; exposed in runtime config and OpenAPI.
    • Admin API & UI: dedicated domain/mailbox settings endpoints and modals, manage-settings action/permission, and settings-specific update flows.
    • Message composer: dynamic recipient limit display/validation per selected sender mailbox.
    • Models/validation: mailbox/maildomain custom_limits fields with server-side validation and enforcement on draft create/update.
  • Tests

    • Added unit, integration, and E2E tests for limits, precedence, validation, permissions, and admin workflows.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 15, 2025

Walkthrough

Adds configurable recipient limits (global, domain, mailbox), enforces them in draft create/update, exposes new management permissions/endpoints and OpenAPI schemas, persists custom_limits on models, updates serializers/permissions, and adds frontend UI/modals, tests, and E2E coverage.

Changes

Cohort / File(s) Summary
OpenAPI & Config
src/backend/core/api/openapi.json, src/backend/core/api/viewsets/config.py
Expose MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE in OpenAPI and runtime config responses.
Global Settings
src/messages/settings.py
Add MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE settings (PositiveInteger defaults).
Models, Migration & Limits Logic
src/backend/core/models.py, src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
Add custom_limits JSONField to MailDomain and Mailbox; validate on clean/save; implement Mailbox.get_max_recipients_per_message() with mailbox→domain→default precedence.
Enums & Abilities
src/backend/core/enums.py, src/frontend/src/hooks/use-ability.ts
Add CAN_MANAGE_SETTINGS / CAN_MANAGE_MAILDOMAIN_SETTINGS abilities and wire them into frontend ability checks.
Permissions
src/backend/core/api/permissions.py
Add CanManageSettings object-level permission enforcing manage-settings ability per resource.
Serializers & Validation
src/backend/core/api/serializers.py
Add CustomLimitsUpdateMixin validating against global max; add MailDomainAdminUpdateSerializer, MailboxSettingsUpdateSerializer; expose max_recipients_per_message on mailbox serializer; adjust admin create/update behavior (custom_limits handled separately).
Viewsets & Endpoints
src/backend/core/api/viewsets/maildomain.py, src/backend/core/api/viewsets/mailbox.py
Add update/patch support for maildomain admin; add mailbox update_settings action (PATCH /settings/) to update custom_limits; annotate querysets with is_domain_admin and user_role; wire CanManageSettings permissions.
Draft Enforcement
src/backend/core/mda/draft.py
Enforce per-message recipient limit (to+cc+bcc) in create_draft and update_draft, using mailbox-effective max and raising ValidationError when exceeded.
OpenAPI Schema Additions
src/backend/core/api/openapi.json
Add schemas and patched variants for MailDomainAdminUpdate*, MailboxSettingsUpdate*, custom_limits, and max_recipients_per_message.
Backend Tests
src/backend/core/tests/api/*, src/backend/core/tests/models/*
Add/update tests for config exposure, admin update flows, permission scoping, limit validation and precedence, and new abilities (unit + integration).
Frontend Config & Hooks
src/frontend/src/features/providers/config.tsx, src/frontend/src/hooks/use-ability.ts
Add new config keys to default AppConfig; include new abilities in useAbility.
Frontend Message Form & Combobox
src/frontend/src/features/forms/components/message-form/index.tsx, src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx, src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx
Make message schema dynamic by mailbox-aware maxRecipients, revalidate recipients on limit change, pass maxRecipients to combobox, and coerce numeric inputs (empty→null).
Frontend Admin — Domain Settings UI
src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx, src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx, src/frontend/src/pages/domain/index.tsx
Add ModalUpdateDomain for domain-level max_recipients_per_message, wire MANAGE_SETTINGS action, validate against global max, invalidate queries and show toasts on success.
Frontend Admin — Mailbox Settings UI
src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx, src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx
Add mailbox MANAGE_SETTINGS action and ModalMailboxManageSettings for per-mailbox limits, submit to dedicated settings endpoint.
E2E Tests & Env
src/e2e/src/__tests__/*, env.d/development/backend.e2e
Add Playwright tests for admin/user limit behavior and set E2E env vars for new settings.

Sequence Diagram(s)

sequenceDiagram
    participant Admin as Admin User
    participant FE as Frontend UI
    participant API as Backend API
    participant DB as Database
    participant Cache as React Query Cache

    Admin->>FE: Open Domain Settings modal
    FE->>API: GET /api/v1.0/maildomains/{id}/
    API->>DB: Fetch MailDomain + custom_limits
    DB-->>API: Return MailDomainAdmin
    API-->>FE: MailDomainAdmin with custom_limits
    FE->>FE: Validate input ≤ MAX_RECIPIENTS_PER_MESSAGE
    Admin->>FE: Submit new limit
    FE->>API: PATCH /api/v1.0/maildomains/{id}/ { custom_limits: {...} }
    API->>API: Permission check (CanManageSettings)
    API->>DB: Update MailDomain.custom_limits
    DB-->>API: Success
    API-->>FE: Return updated MailDomainAdmin
    FE->>Cache: Invalidate maildomain queries
    FE->>Admin: Show success toast / close modal

    %% Message compose flow
    participant User as End User
    participant Form as Message Form
    participant DraftAPI as Draft API

    User->>Form: Select mailbox
    Form->>API: GET mailbox (abilities, max_recipients_per_message)
    API->>DB: Resolve effective limit via mailbox.get_max_recipients_per_message()
    DB-->>API: Return limit
    API-->>Form: maxRecipients
    Form->>Form: Enforce total(to+cc+bcc) ≤ maxRecipients
    User->>Form: Submit draft
    Form->>DraftAPI: POST /messages/drafts
    DraftAPI->>API: create_draft() validation (enforce same limit)
    DraftAPI->>DB: Persist draft
    DB-->>DraftAPI: Return draft created
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Pay extra attention to:
    • Recipient counting and partial-update logic in src/backend/core/mda/draft.py.
    • Precedence and global-bound enforcement in Mailbox.get_max_recipients_per_message() and serializer validation.
    • Permission mapping for CanManageSettings across MailDomain vs Mailbox (backend and frontend).
    • Consistency between OpenAPI schemas, serializers, and frontend request payloads (custom_limits shape).
    • Frontend dynamic schema revalidation and modal submission/error handling flows.

Possibly related PRs

Poem

🐇 I nibble limits, tidy each line,
Per-domain burrows, per-mailbox fine.
Global fences keep the crowd in tow,
Admins can tune the numbers so.
Hop, save, and mail — neat carrots in a row.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: implementing MAX_RECIPIENTS_PER_MESSAGE handling for message recipients. It accurately reflects the primary objective of adding recipient limits across the system.
Docstring Coverage ✅ Passed Docstring coverage is 89.19% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sdem/recipients_limit_per_msg

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/backend/core/models.py (1)

304-316: Missing validation for custom_limits in MailDomain.clean.

The MailDomain.clean method validates custom_attributes but not custom_limits. In contrast, Mailbox.clean (lines 535-552) validates its custom_limits field. This inconsistency could allow invalid data in MailDomain.custom_limits.

Consider adding validation similar to Mailbox.clean:

     def clean(self):
         """Validate custom attributes and limits."""
         # Custom attributes schema
         try:
             jsonschema.validate(
                 self.custom_attributes, settings.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN
             )
         except jsonschema.ValidationError as exception:
             raise ValidationError(
                 {"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"}
+                )
+            if "max_recipients_per_message" in self.custom_limits:
+                value = self.custom_limits["max_recipients_per_message"]
+                if value is not None and (not isinstance(value, int) or value < 1):
+                    raise ValidationError(
+                        {
+                            "custom_limits": "max_recipients_per_message must be a positive integer"
+                        }
+                    )
+
         super().clean()
src/backend/core/api/serializers.py (1)

1049-1073: Add custom_limits to read_only_fields or handle it in update().

The custom_limits field is added to fields but not to read_only_fields, making it appear writable. However, the update() method (lines 1162-1212) doesn't process custom_limits - it only handles metadata fields like full_name, name, and custom_attributes. This inconsistency could confuse API consumers who expect updates to persist.

Since the dedicated /settings/ endpoint handles custom_limits updates, consider adding it to read_only_fields:

         read_only_fields = [
             "id",
             "domain_name",
             "is_identity",
             "accesses",  # List of users and their roles
             "created_at",
             "updated_at",
             "can_reset_password",
             "contact",
+            "custom_limits",
         ]
🧹 Nitpick comments (29)
src/e2e/src/__tests__/message-max-recipients.spec.ts (1)

17-33: Consider making the expected limit configurable.

The test hardcodes 100 as the expected limit. If the test environment configuration for MAX_DEFAULT_RECIPIENTS_PER_MESSAGE changes, this test will fail. Consider reading the expected value from test fixtures or environment configuration to make the test more maintainable.

src/frontend/src/features/forms/components/message-form/index.tsx (2)

29-29: Remove unused import.

useConfigRetrieve is imported but never used in this file. Only useConfig is used.

-import { useConfigRetrieve } from "@/features/api/gen/config/config";

230-235: Consider extracting duplicate limit calculation logic.

The logic for computing the recipient limit from mailbox/config is duplicated in the initial state (lines 230-235) and the useEffect (lines 255-266). Extract to a helper function for maintainability.

const getMaxRecipientsLimit = (mailbox: Mailbox | undefined, config: AppConfig) => {
    const defaultLimit = Number(config.MAX_DEFAULT_RECIPIENTS_PER_MESSAGE ?? config.MAX_RECIPIENTS_PER_MESSAGE);
    const mailboxLimit = mailbox?.max_recipients_per_message;
    return mailboxLimit ? Number(mailboxLimit) : defaultLimit;
};

Also applies to: 255-266

src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2)

1-1: Remove unused import.

useConfigRetrieve is imported but not used in this file.

-import { useConfigRetrieve, useContactsList } from "@/features/api/gen";
+import { useContactsList } from "@/features/api/gen";

69-74: Potential validation loop concern.

Calling trigger(['to', 'cc', 'bcc']) on every change in any recipient field will trigger validation for all three fields. If each field's onChange triggers all three, this could cause redundant validation cycles. The approach works but may cause minor performance overhead.

Since the parent message-form already has a similar trigger (line 275), consider whether both are necessary.

src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1)

33-41: Validation and RHF wiring are correct; consider minor cleanups for readability and form control

Overall flow (defaulting from mailbox.custom_limits, validating against global max, and submitting via useMaildomainsMailboxesSettingsUpdate) looks sound.

Two small improvements to keep things maintainable:

  1. Avoid repeated Number(value) conversions and tighten the guard:
-    const value = data.max_recipients_per_message.trim();
-    if (value !== '' && (isNaN(Number(value)) || Number(value) < 1 || !Number.isInteger(Number(value)))) {
+    const value = data.max_recipients_per_message.trim();
+    if (value === '') {
+      // handled as "use default" below
+    } else {
+      const parsed = Number(value);
+      if (!Number.isInteger(parsed) || parsed < 1) {
+        form.setError('max_recipients_per_message', {
+          message: t('Please enter a valid positive integer or leave empty.'),
+        });
+        return;
+      }
+      // Validate that the value does not exceed the global maximum
+      if (parsed > MAX_RECIPIENTS_PER_MESSAGE) {
+        form.setError('max_recipients_per_message', {
+          message: t('The limit cannot exceed the global maximum of {{max}} recipients.', {
+            max: MAX_RECIPIENTS_PER_MESSAGE,
+          }),
+        });
+        return;
+      }
+    }
  1. RHF + Input control: you currently both register the field and pass an explicit value={maxRecipientsValue}. With react-hook-form, the usual pattern is either:
  • let register control the value (drop the value prop), or
  • use Controller if Input is not fully compatible with register.

If Input works with register, you can simplify to:

-  const { handleSubmit, register, formState: { errors }, watch } = form;
-  const maxRecipientsValue = watch('max_recipients_per_message');
+  const { handleSubmit, register, formState: { errors } } = form;
...
-              {...register('max_recipients_per_message')}
+              {...register('max_recipients_per_message')}
...
-              value={maxRecipientsValue}

This avoids potential controlled/uncontrolled warnings and keeps the form logic closer to standard RHF usage.

Also applies to: 48-65, 97-127

src/backend/core/tests/models/test_mailbox.py (1)

106-122: Mailbox manage_settings coverage is good; consider adding a positive-case test

The extra abilities["manage_settings"] is False assertions across no‑access/viewer/editor/admin/sender cases, plus the clarified admin docstring, make the permission model around mailbox settings much clearer and aligned with “domain admin only” semantics.

You might optionally add a dedicated test exercising the case where a domain admin (or superuser) does get manage_settings=True on a mailbox, mirroring the domain-level tests, so the positive path is also explicitly covered.

Also applies to: 123-145, 146-168, 169-193, 194-215

src/backend/core/tests/models/test_maildomain.py (1)

130-162: Domain manage_settings tests align with implementation; double-check superuser get semantics

The new assertions that abilities["manage_settings"] is False for admins and True for superusers correctly reflect MailDomain.get_abilities (settings reserved to superusers).

In the superuser test you also assert abilities["get"] is False, which matches the current implementation (CAN_READ is based solely on domain role, not is_superuser). If the intent is that superusers may update/delete/configure domains but not “read” them via get_abilities, this is fine; otherwise you might want to adjust get_abilities to treat superusers as readable too (and update this assertion accordingly).

Also applies to: 163-177

src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (5)

46-47: Remove debug console.log statements before merge.

These debug statements should be removed as they add noise to test output.

-    console.log(limitMatch);
-    console.log(limit);

51-51: Fix grammatical error in test description.

The test name is grammatically incorrect and doesn't clearly convey the expected behavior.

-  test("should super user can customize domain max recipients per message more than the global limit", async ({ page, browserName }) => {
+  test("should not allow super user to exceed global max recipients limit", async ({ page, browserName }) => {

65-65: Replace waitForTimeout with explicit wait conditions.

Using fixed timeouts is a test anti-pattern that can cause flaky tests. Prefer waiting for specific conditions.

-    await page.waitForTimeout(50);
+    await maxRecipientsPerMessageInput.waitFor({ state: "visible" });

Also applies to: 78-78


68-68: Remove debug console.log statement.

-    console.log(maxRecipientsPerMessageInput);

84-95: Incomplete test with TODO placeholder.

This test is incomplete and only contains setup steps. Either implement the full test or remove it to avoid confusion in test reports.

Would you like me to help implement the full test case for domain admin customizing mailbox max recipients?

src/backend/core/mda/draft.py (2)

110-112: Consider using i18n for the error message.

The error message uses an f-string, but other validation errors in this file use _() for translation. For consistency with the rest of the codebase:

-        raise drf.exceptions.ValidationError(
-            f"Too many recipients: {total_recipients} (maximum is {max_recipients})."
-        )
+        raise drf.exceptions.ValidationError(
+            _("Too many recipients: %(total)s (maximum is %(max)s).")
+            % {"total": total_recipients, "max": max_recipients}
+        )

272-274: Consider using i18n for consistency.

Same as in create_draft - consider using the translation function for consistency:

-        raise drf.exceptions.ValidationError(
-            f"Too many recipients: {total_after_update} (maximum is {max_recipients})."
-        )
+        raise drf.exceptions.ValidationError(
+            _("Too many recipients: %(total)s (maximum is %(max)s).")
+            % {"total": total_after_update, "max": max_recipients}
+        )
src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (1)

72-72: Avoid as any cast - consider proper typing.

Casting to any bypasses TypeScript's type safety. Consider adding proper typing for the config:

-  const { SCHEMA_CUSTOM_ATTRIBUTES_USER } = useConfig() as any;
+  const { SCHEMA_CUSTOM_ATTRIBUTES_USER } = useConfig() as { SCHEMA_CUSTOM_ATTRIBUTES_USER?: { properties?: Record<string, ItemJsonSchema> } };

Alternatively, extend the Config type in the config provider to include this property with its correct type.

src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (2)

35-35: Avoid as any cast for type safety.

The MailDomainAdmin type may need to be extended to include custom_limits. Consider updating the generated types or using a more specific type assertion:

-      max_recipients_per_message: (domain as any)?.custom_limits?.max_recipients_per_message?.toString() ?? '',
+      max_recipients_per_message: domain.custom_limits?.max_recipients_per_message?.toString() ?? '',

This requires ensuring custom_limits is properly typed in MailDomainAdmin.

Verify that the MailDomainAdmin type includes custom_limits in the generated API types. If not, the OpenAPI schema may need to be updated to include this field.


120-123: Potential double-translation issue.

If error already contains a translated message from the API response (line 100-101), wrapping it in t(error) could cause unexpected behavior. Consider:

-          <Banner type="error">
-            {t(error)}
-          </Banner>
+          <Banner type="error">
+            {error}
+          </Banner>

The error message is already constructed with t() in the catch block's fallback (line 100).

src/frontend/src/pages/domain/index.tsx (3)

2-2: Unused import: useModal.

The useModal hook is imported but not used in this file.

-import { Button, DataGrid, usePagination, useModal } from "@openfun/cunningham-react";
+import { Button, DataGrid, usePagination } from "@openfun/cunningham-react";

104-123: Consider simplifying the IIFE pattern for modal rendering.

The immediately-invoked function expression works but is unconventional in React JSX. The freshDomain lookup could be moved to a useMemo or inline within the component for consistency with how ModalMaildomainManageAccesses is rendered above.

That said, the pattern ensures fresh data is used when the modal opens, which is a valid approach for this use case.


34-39: Consider extracting the query invalidation logic to reduce duplication.

The same invalidation pattern appears in both refetchMaildomains (line 34-39) and handleCreateDomain (line 179-182). A shared helper would improve maintainability.

// Could be extracted to a shared utility
const invalidateMaildomainsList = () => {
  queryClient.invalidateQueries({
    queryKey: getMaildomainsListQueryOptions().queryKey,
    exact: false,
  });
};

Also applies to: 176-182

src/backend/core/tests/api/test_admin_maildomains_update.py (1)

50-52: Strengthen the assertion for verifying limits were NOT updated.

The assertion maildomain.custom_limits.get("max_recipients_per_message") != 50 would pass if custom_limits is an empty dict (returns None). Consider asserting the original state explicitly for clarity.

         # Verify the limit was NOT updated
         maildomain.refresh_from_db()
-        assert maildomain.custom_limits.get("max_recipients_per_message") != 50
+        assert maildomain.custom_limits == {} or maildomain.custom_limits.get("max_recipients_per_message") is None

Alternatively, capture the original value before the request and assert it remains unchanged.

src/backend/core/models.py (1)

623-652: Role initialization change from None to 0.

Changing role = None to role = 0 is functionally equivalent here since 0 is falsy and bool(role) evaluates the same. However, this subtly changes semantics: role = 0 could be confused with a valid role value in systems using 0-indexed enums.

Since MailboxRoleChoices starts at 1 (VIEWER=1), this is safe, but consider documenting or using a named constant for clarity.

src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (1)

218-225: Remove unnecessary type assertion for updated_at field.

The MailboxAdmin interface already includes updated_at as a properly typed field (readonly updated_at: string), so the as any cast is unnecessary:

-                        key={`${editedMailbox.id}-${(editedMailbox as any).updated_at}`}
+                        key={`${editedMailbox.id}-${editedMailbox.updated_at}`}
src/backend/core/api/serializers.py (1)

976-991: Consider validating for non-positive values.

The validation correctly prevents exceeding the global maximum, but doesn't check for negative or zero values. If max_recipients_per_message: -1 or max_recipients_per_message: 0 is submitted, it would pass validation but could cause unexpected behavior downstream.

     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 a positive integer.")
+                    )
                 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
src/backend/core/api/openapi.json (4)

221-235: Config: recipient-limit keys mostly consistent, consider requiredness of default

The new MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE entries and descriptions are clear and aligned with the PR goal. If MAX_DEFAULT_RECIPIENTS_PER_MESSAGE is guaranteed to be present in the response, it would be more accurate to also include it in the required list; otherwise, consider explicitly noting that it may be omitted so client generators don’t over-assume its presence.

Also applies to: 237-250


3669-3735: Mailbox /settings/ endpoint matches the intended custom_limits workflow

The new /maildomains/{maildomain_pk}/mailboxes/{id}/settings/ PATCH endpoint cleanly separates mailbox settings updates via PatchedMailboxSettingsUpdateRequest and returns MailboxSettingsUpdate, which fits the “settings-only” contract described in the PR. Optionally, you might later add JSON schemas for the 400/403 responses for better client DX, but this is not blocking.


6194-6209: MailboxAdmin.custom_limits: consider making this explicitly readOnly

Exposing custom_limits on MailboxAdmin is useful for admin UIs, but since you’ve introduced a dedicated /settings/ endpoint for modifying these values, you may want to mark this property as readOnly: true here to avoid implying that it’s writable via the general mailbox admin PATCH endpoint. That keeps the write surface clearly funneled through /settings/.


6348-6356: MailboxAdminUpdateMetadataRequest: avoid suggesting custom_limits is updatable here if only /settings/ should write it

Adding custom_limits to MailboxAdminUpdateMetadataRequest suggests clients can also update limits via the main mailbox PATCH metadata payload, which partially contradicts the dedicated /settings/ endpoint and the MailboxAdminCreate note. If the backend ignores this field in metadata updates, consider removing it from this request schema to keep the documented write path unique; if you do intend to support both, it’s fine but worth being aware that you now have two public write surfaces.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 36d9352 and bc5799f.

⛔ Files ignored due to path filters (17)
  • src/frontend/src/features/api/gen/maildomains/maildomains.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_create.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_settings_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts is excluded by !**/gen/**
📒 Files selected for processing (30)
  • src/backend/core/api/openapi.json (18 hunks)
  • src/backend/core/api/permissions.py (1 hunks)
  • src/backend/core/api/serializers.py (9 hunks)
  • src/backend/core/api/viewsets/config.py (4 hunks)
  • src/backend/core/api/viewsets/maildomain.py (5 hunks)
  • src/backend/core/enums.py (2 hunks)
  • src/backend/core/mda/draft.py (3 hunks)
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py (1 hunks)
  • src/backend/core/models.py (9 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_list.py (3 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_update.py (1 hunks)
  • src/backend/core/tests/api/test_config.py (2 hunks)
  • src/backend/core/tests/api/test_mailboxes.py (4 hunks)
  • src/backend/core/tests/api/test_messages_create.py (2 hunks)
  • src/backend/core/tests/models/test_mailbox.py (6 hunks)
  • src/backend/core/tests/models/test_maildomain.py (2 hunks)
  • src/backend/messages/settings.py (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients.spec.ts (1 hunks)
  • src/frontend/src/features/forms/components/message-form/index.tsx (10 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (7 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (4 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1 hunks)
  • src/frontend/src/features/providers/config.tsx (1 hunks)
  • src/frontend/src/hooks/use-ability.ts (3 hunks)
  • src/frontend/src/pages/domain/index.tsx (6 hunks)
🧰 Additional context used
📓 Path-based instructions (7)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/backend/core/enums.py
  • src/backend/core/mda/draft.py
  • src/backend/messages/settings.py
  • src/backend/core/tests/models/test_maildomain.py
  • src/backend/core/models.py
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/tests/models/test_mailbox.py
  • src/backend/core/api/permissions.py
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_admin_maildomains_update.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/api/test_config.py
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
  • src/backend/core/api/viewsets/maildomain.py
  • src/backend/core/api/serializers.py
src/backend/**/{tests.py,tests/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/{tests.py,tests/**/*.py}: Use Django’s testing tools (pytest-django) to ensure code quality and reliability
Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases

Files:

  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/backend/core/tests/models/test_maildomain.py
  • src/backend/core/tests/models/test_mailbox.py
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_admin_maildomains_update.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/api/test_config.py
src/backend/**/{settings.py,middleware.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Use middleware judiciously for cross-cutting concerns (authentication, logging, caching)

Files:

  • src/backend/messages/settings.py
src/backend/**/settings.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/settings.py: Leverage Django’s caching framework (e.g., Redis/Memcached) where appropriate
Use Django’s cache framework with a backend like Redis or Memcached to reduce DB load
Optimize static file handling using Django’s staticfiles pipeline (e.g., WhiteNoise)

Files:

  • src/backend/messages/settings.py
src/backend/**/{models.py,forms.py,views.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Keep business logic in models and forms; keep views thin and focused on request handling

Files:

  • src/backend/core/models.py
src/backend/**/{models.py,migrations/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Implement database indexing and query optimization (Model Meta indexes, constraints)

Files:

  • src/backend/core/models.py
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
src/backend/{templates/**/*.html,**/serializers.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Use Django templates for HTML and DRF serializers for JSON responses

Files:

  • src/backend/core/api/serializers.py
🧠 Learnings (3)
📚 Learning: 2025-09-02T12:42:29.083Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/pages/domain/index.tsx:0-0
Timestamp: 2025-09-02T12:42:29.083Z
Learning: The domain admin pages in src/frontend/src/pages/domain/ use AdminLayout, not the main layout. Toast notifications for admin pages should be handled via a Toaster instance in the AdminLayout component.

Applied to files:

  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx
  • src/frontend/src/pages/domain/index.tsx
  • src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx
📚 Learning: 2025-09-02T12:20:22.006Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/features/layouts/components/admin/modal-create-domain/index.tsx:32-37
Timestamp: 2025-09-02T12:20:22.006Z
Learning: In the messages application, `SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN` from the config defaults to an empty object `{}` rather than being potentially undefined, so null checks are not needed when using this configuration value.

Applied to files:

  • src/frontend/src/features/forms/components/message-form/index.tsx
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
  • src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Use Django REST Framework viewsets for API endpoints

Applied to files:

  • src/backend/core/api/viewsets/maildomain.py
🧬 Code graph analysis (14)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (5)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (14-39)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • useMaildomainsMailboxesSettingsUpdate (1869-1901)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/backend/core/mda/draft.py (3)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (292-294)
src/backend/core/models.py (1)
  • get_max_recipients_per_message (735-764)
src/backend/core/enums.py (1)
  • MessageRecipientTypeChoices (31-36)
src/frontend/src/pages/domain/index.tsx (4)
src/frontend/src/hooks/use-ability.ts (1)
  • Abilities (34-39)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • getMaildomainsListQueryOptions (102-131)
src/frontend/src/features/api/gen/models/mail_domain_admin.ts (1)
  • MailDomainAdmin (13-26)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1)
  • ModalUpdateDomain (24-153)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (5)
src/frontend/src/features/api/gen/models/mail_domain_admin.ts (1)
  • MailDomainAdmin (13-26)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/fetch-api.ts (1)
  • fetchAPI (12-40)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/backend/core/tests/models/test_maildomain.py (1)
src/backend/core/models.py (3)
  • get_abilities (225-240)
  • get_abilities (351-389)
  • get_abilities (619-677)
src/backend/core/api/permissions.py (3)
src/backend/core/api/serializers.py (4)
  • get_abilities (132-141)
  • get_abilities (165-167)
  • get_abilities (288-290)
  • get_abilities (910-912)
src/backend/core/models.py (5)
  • get_abilities (225-240)
  • get_abilities (351-389)
  • get_abilities (619-677)
  • MailDomain (243-426)
  • Mailbox (483-764)
src/backend/core/enums.py (2)
  • MailDomainAbilities (96-101)
  • MailboxAbilities (104-116)
src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (2)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (14-39)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1)
  • ModalMailboxManageLimits (24-143)
src/frontend/src/features/forms/components/message-form/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
src/e2e/src/__tests__/message-max-recipients.spec.ts (1)
src/e2e/src/utils-test.ts (1)
  • signInKeycloakIfNeeded (6-39)
src/backend/core/tests/api/test_admin_maildomains_update.py (4)
src/backend/core/enums.py (2)
  • MailDomainAccessRoleChoices (48-51)
  • MailboxRoleChoices (15-21)
src/backend/core/factories.py (3)
  • UserFactory (20-31)
  • MailDomainFactory (51-57)
  • MailDomainAccessFactory (112-122)
src/backend/core/models.py (1)
  • get_max_recipients_per_message (735-764)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (292-294)
src/backend/core/tests/api/test_messages_create.py (4)
src/backend/core/tests/models/test_mailbox.py (2)
  • mailbox (23-25)
  • user (17-19)
src/backend/core/tests/api/conftest.py (1)
  • mailbox (59-61)
src/backend/core/factories.py (2)
  • MailboxFactory (60-96)
  • MailDomainFactory (51-57)
src/backend/core/enums.py (1)
  • MailboxRoleChoices (15-21)
src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py (1)
src/backend/core/api/fields.py (1)
  • JSONField (8-25)
src/backend/core/api/viewsets/maildomain.py (2)
src/backend/core/api/permissions.py (1)
  • CanManageSettings (455-482)
src/backend/core/api/serializers.py (3)
  • MailDomainAdminWriteSerializer (959-973)
  • MailDomainAdminUpdateSerializer (994-1005)
  • MailboxSettingsUpdateSerializer (1008-1016)
src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/forms/components/zod-json-schema-serializer/index.ts (1)
  • ItemJsonSchema (4-8)
🔇 Additional comments (49)
src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx (1)

106-112: LGTM! The conversion to Number and empty-string-to-null handling correctly addresses validation errors for numeric inputs.

One consideration: if a numeric field is required, returning null for empty string may cause a validation mismatch (expecting number, receiving null). Verify that the Zod schema for required numeric fields uses .nullable() or .optional() to align with this behavior.

src/frontend/src/hooks/use-ability.ts (3)

15-16: LGTM! The new CAN_MANAGE_SETTINGS ability for mailboxes follows the established pattern.


27-28: LGTM! The new CAN_MANAGE_MAILDOMAIN_SETTINGS ability for mail domains is correctly added.


84-87: LGTM! The switch cases for the new settings abilities properly delegate to the boolean check on the resource's abilities.

src/frontend/src/features/forms/components/message-form/index.tsx (2)

269-277: Consider adding schema to dependency array or memoizing trigger call.

The form.trigger uses the resolver which may be stale (as noted above). Additionally, this effect runs every time maxRecipientsLimit changes, but the form reference is stable so dependencies look correct.


614-614: LGTM! maxRecipients prop is correctly passed to all recipient fields (to, cc, bcc).

Also applies to: 631-631, 647-647

src/frontend/src/features/providers/config.tsx (1)

26-28: Code correctly adds recipient limit configuration. The generated ConfigRetrieve200 type includes both MAX_RECIPIENTS_PER_MESSAGE (required) and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE (optional), with proper documentation. The AppConfig type correctly derives these fields, and the default values of 0 appropriately serve as fallbacks until the backend config is loaded.

src/backend/core/tests/api/test_admin_maildomains_list.py (1)

360-380: Abilities expectations for manage_settings look consistent

The added assertions that abilities["manage_settings"] is False for domain admins in both detail and list responses align with the backend MailDomain.get_abilities behavior (settings reserved to superusers). This keeps API and tests in sync.

Also applies to: 382-404

src/backend/core/tests/api/test_config.py (1)

18-32: Config test correctly covers new max-recipient settings

Including MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE in both override_settings and the asserted JSON response matches the ConfigView additions and guards against regressions on these public config keys.

Also applies to: 44-58

src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py (1)

6-22: Migration for custom_limits fields looks correct

Both Mailbox and MailDomain get JSONField(blank=True, default=dict, ...) for custom_limits, which is appropriate for optional per-entity limits and avoids shared mutable defaults by passing the callable dict. No data migration needed here.

src/backend/core/enums.py (1)

101-101: LGTM!

The new CAN_MANAGE_SETTINGS ability options are consistently added to both MailDomainAbilities and MailboxAbilities, following the existing naming conventions and patterns.

Also applies to: 116-116

src/backend/core/api/permissions.py (1)

455-482: LGTM!

The CanManageSettings permission class correctly implements object-level permission checks for both MailDomain and Mailbox objects. The implementation properly:

  • Validates user authentication
  • Retrieves abilities via obj.get_abilities(request.user)
  • Uses type-specific ability enums for the permission check
src/backend/messages/settings.py (1)

252-263: LGTM!

The new recipient limit settings are well-documented and follow the existing configuration patterns. The comments clearly explain the hierarchy:

  • MAX_RECIPIENTS_PER_MESSAGE: absolute system maximum
  • MAX_DEFAULT_RECIPIENTS_PER_MESSAGE: default when no custom limit is set

The model's get_max_recipients_per_message method correctly enforces these constraints using min().

src/backend/core/tests/api/test_mailboxes.py (2)

370-371: LGTM!

The test correctly verifies that manage_settings is False for mailbox admins. Per the model's get_abilities logic, only domain admins can manage mailbox settings, not mailbox-level admins.

Also applies to: 390-391


422-422: LGTM!

These assertions correctly verify that editor and viewer roles do not have manage_settings ability, which aligns with the permission model where only domain admins can manage mailbox settings.

Also applies to: 470-470

src/backend/core/mda/draft.py (2)

101-112: LGTM on the validation logic.

The recipient limit enforcement is correctly implemented:

  • Normalizes recipient lists to empty arrays
  • Uses mailbox.get_max_recipients_per_message() for the effective limit
  • Validates before processing to fail fast

252-274: LGTM on the partial update handling.

The recipient limit validation during updates correctly handles partial updates by:

  1. Computing current counts per recipient type
  2. Using update values when provided, falling back to current counts
  3. Validating the total after applying updates
src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (2)

124-126: LGTM on the explicit type casting.

The explicit cast to [string, ItemJsonSchema][] is a reasonable workaround for TypeScript's type inference limitations with Object.entries(). This ensures type safety when iterating over the custom attributes.

Also applies to: 135-136


415-426: LGTM!

The JSON schema field rendering is correctly implemented with proper key naming and error state handling.

src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1)

48-105: LGTM on the overall submit flow.

The implementation correctly:

  • Validates input as positive integer or empty
  • Enforces global maximum limit
  • Sends PATCH request with proper payload structure
  • Invalidates relevant queries on success
  • Handles errors with appropriate fallbacks
  • Shows success toast notification
src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (2)

60-63: LGTM! Clean integration of the new manage settings action.

The handler follows the established pattern used by other actions (handleManageAccess, handleUpdate, etc.), and the wiring to ActionsRow is consistent.

Also applies to: 150-150


263-268: Verify separator logic for the "Limits" menu item.

The showSeparator is set to !onResetPassword, meaning the separator appears when reset password is unavailable. This seems intentional to visually group "Edit" and "Limits" together, separating them from "Delete". However, confirm this matches the desired UX.

src/frontend/src/pages/domain/index.tsx (1)

199-225: LGTM! Clean permission-gated actions.

The ActionsCell correctly checks both canManageAccesses and canManageSettings separately, and returns null when neither permission is available. This follows the pattern established in the codebase.

src/backend/core/tests/api/test_admin_maildomains_update.py (3)

242-245: MailboxAccessFactory created without explicit role.

This factory call creates mailbox access but doesn't specify a role. Per the factory definition in factories.py, MailboxAccess defaults to VIEWER. If the test intends to verify that admin access to the mailbox alone is insufficient (without domain admin), this is fine. Just confirming the intent.


25-186: LGTM! Comprehensive test coverage for maildomain custom_limits.

The test class covers:

  • Admin cannot update (403)
  • Superuser can update (200)
  • Clearing values works
  • Unauthorized access (404)
  • Non-admin forbidden (404)
  • Other fields cannot be changed
  • Global max enforcement (400)

This aligns well with the permission model where only superusers can manage domain-level settings.


189-363: LGTM! Good coverage for mailbox custom_limits and priority logic.

The tests correctly verify:

  • Domain admin can update mailbox limits
  • Mailbox limits take priority over domain limits
  • Domain limits used when mailbox has none
  • Global max cannot be exceeded
  • Mailbox admin alone cannot update settings (needs domain admin)
  • Name updates work separately from settings
src/backend/core/tests/api/test_messages_create.py (4)

810-836: LGTM! Clear test for basic recipient limit enforcement.

The test correctly verifies that when MAX_RECIPIENTS_PER_MESSAGE=2, a draft with 3 recipients (to + cc + bcc) is rejected with 400.


838-934: LGTM! Good coverage for custom limit overrides.

These tests correctly verify:

  • Mailbox custom_limits overrides global settings
  • Domain custom_limits overrides global settings
  • Both success and failure cases are tested

936-986: LGTM! Validates priority: mailbox > domain.

This test correctly verifies that when both mailbox (limit=2) and domain (limit=5) have custom limits, the mailbox limit takes precedence.


988-1031: LGTM! Tests recipient limit enforcement on draft updates.

Verifies that the limit is also enforced when updating an existing draft, not just on creation.

src/backend/core/models.py (3)

735-764: LGTM! Well-documented priority logic for recipient limits.

The method correctly implements the priority chain:

  1. Mailbox custom_limits
  2. Domain custom_limits
  3. Global default (capped by global max)

The min() calls ensure values never exceed MAX_RECIPIENTS_PER_MESSAGE.

Note: The hasattr checks (lines 745, 751-754) are technically redundant since custom_limits is a model field that always exists (defaulting to {}), but they're harmless and add defensive coding.


387-388: LGTM! Superuser-only access for domain settings management.

The comment clearly explains the restriction, and the implementation correctly ties CAN_MANAGE_SETTINGS to user.is_superuser for maildomains.


667-667: LGTM! Domain admin check for mailbox settings.

CAN_MANAGE_SETTINGS for mailboxes correctly derives from is_domain_admin, allowing domain administrators to manage settings for mailboxes under their domain.

src/backend/core/api/viewsets/maildomain.py (4)

37-70: LGTM - UpdateModelMixin and permission/serializer routing look correct.

The addition of UpdateModelMixin and the corresponding get_permissions() and get_serializer_class() overrides properly route update actions to use CanManageSettings permission and MailDomainAdminUpdateSerializer. The permission check ensures only users with settings management ability can update domain limits.


167-180: LGTM - Permission stacking for update_settings is appropriate.

The get_permissions() method correctly combines existing domain admin permissions with CanManageSettings, ensuring that only domain administrators with settings management ability can access this action.


356-386: LGTM - New update_settings endpoint is well-structured.

The endpoint follows DRF patterns correctly: proper OpenAPI documentation, uses the appropriate serializer, and permission checks are enforced via the get_permissions() override combined with self.get_object(). The partial update approach allows updating only the fields present in the request.


246-289: Remove custom_limits from the OpenAPI schema for partial_update.

The OpenAPI schema at line 261 includes custom_limits in the MailboxAdminUpdateMetadata, but the MailboxAdminSerializer.update() method does not process this field. Additionally, MailboxAdminCreateSerializer explicitly excludes custom_limits with a comment stating it "can only be modified via the dedicated /settings/ endpoint." This confirms that custom_limits updates should not be accepted via partial_update. Remove custom_limits from the inline serializer to avoid misleading users into sending data that will be silently ignored.

Likely an incorrect or invalid review comment.

src/backend/core/api/serializers.py (4)

220-231: LGTM - max_recipients_per_message field added correctly.

The new max_recipients_per_message field is properly declared as a read-only SerializerMethodField and the getter delegates to the model's get_max_recipients_per_message() method, maintaining separation of concerns.

Also applies to: 292-294


994-1016: LGTM - Update serializers are minimal and focused.

Both MailDomainAdminUpdateSerializer and MailboxSettingsUpdateSerializer correctly inherit from CustomLimitsUpdateMixin first (for proper MRO), expose only the necessary fields, and are appropriately scoped for their single purpose of updating custom_limits.


886-894: LGTM - custom_limits correctly exposed in admin serializer.

The custom_limits field is added to the read-only MailDomainAdminSerializer, allowing administrators to view the current limits. The field remains read-only as updates go through the dedicated update serializer.


1215-1241: LGTM - Clean exclusion of custom_limits from creation response.

The list comprehension approach to exclude custom_limits from the parent's fields is readable, and the docstring clearly documents the design decision that limits can only be modified via the dedicated /settings/ endpoint.

src/backend/core/api/openapi.json (8)

3041-3141: Maildomain update/partial_update endpoints look correctly wired

The new PUT/PATCH operations for /maildomains/{maildomain_pk}/ correctly use the focused MailDomainAdminUpdateRequest/PatchedMailDomainAdminUpdateRequest payloads and return MailDomainAdminUpdate, which keeps the update surface constrained to custom_limits. This shape looks coherent with the admin use case.


5719-5785: MailDomainAdmin: exposing custom_limits and manage_settings is consistent

Adding a read-only custom_limits field and the manage_settings ability (plus updating the required list accordingly) keeps the domain admin surface self-describing and matches the new permissions model. This schema looks internally consistent.


5787-5813: MailDomainAdminUpdate schemas are minimal and focused*

The MailDomainAdminUpdate and MailDomainAdminUpdateRequest schemas restrict updates to custom_limits and keep id read-only on the response, which is a good, narrow surface for administrative limit changes.


5815-5865: MailDomainAdminWrite description tweaks align with actual usage*

The updated descriptions for MailDomainAdminWrite and MailDomainAdminWriteRequest clarifying that they serve the “creating admin view” path are accurate and match the corresponding POST /maildomains/ endpoint semantics.

Also applies to: 5863-5887


6223-6225: MailboxAdminCreate description clearly documents exclusion of custom_limits

The note explaining that custom_limits is excluded from the create response and is only modifiable via /settings/ is helpful and matches the separation of concerns introduced by the new settings endpoint.


6392-6409: MailboxSettingsUpdate schema matches the settings endpoint contract

MailboxSettingsUpdate mirrors the /settings/ response needs (read-only id, customizable custom_limits) and keeps the shape small and focused, which is appropriate for this specialized endpoint.


7111-7119: PatchedMailDomainAdminUpdateRequest is consistent with full update variant

The PatchedMailDomainAdminUpdateRequest schema mirrors MailDomainAdminUpdateRequest but omits required, which is correct for partial updates and aligns with the new PATCH /maildomains/{maildomain_pk}/ behavior.


7142-7150: PatchedMailboxSettingsUpdateRequest correctly models partial settings updates

The PatchedMailboxSettingsUpdateRequest schema is the appropriate partial counterpart of MailboxSettingsUpdateRequest (via custom_limits only) and works well with the /settings/ PATCH endpoint.

@sdemagny sdemagny force-pushed the sdem/recipients_limit_per_msg branch from bc5799f to c2ce499 Compare December 15, 2025 21:06
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/backend/core/models.py (1)

304-316: Misleading docstring or missing validation.

The docstring claims "Validate custom attributes and limits," but the method only validates custom_attributes. Unlike Mailbox.clean() (lines 535-551), there's no validation logic for custom_limits structure or values.

If validation for MailDomain.custom_limits is intentional, update the docstring:

-    def clean(self):
-        """Validate custom attributes and limits."""
+    def clean(self):
+        """Validate custom attributes."""

Otherwise, consider adding validation similar to Mailbox.clean():

     def clean(self):
         """Validate custom attributes and limits."""
         # Custom attributes schema
         try:
             jsonschema.validate(
                 self.custom_attributes, settings.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN
             )
         except jsonschema.ValidationError as exception:
             raise ValidationError(
                 {"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"}
+                )
+            if "max_recipients_per_message" in self.custom_limits:
+                value = self.custom_limits["max_recipients_per_message"]
+                if value is not None and (not isinstance(value, int) or value < 1):
+                    raise ValidationError(
+                        {
+                            "custom_limits": "max_recipients_per_message must be a positive integer"
+                        }
+                    )
+
         super().clean()
src/backend/core/api/viewsets/maildomain.py (1)

256-265: Inconsistent: custom_limits in partial_update schema but not supported.

The inline serializer schema includes custom_limits (line 261), suggesting it can be updated via the regular partial_update endpoint. However, based on the PR design, custom_limits should only be modifiable via the dedicated /settings/ endpoint.

If custom_limits shouldn't be settable via regular partial_update, remove it from the inline schema to avoid confusion:

                     fields={
                         "full_name": drf_serializers.CharField(
                             required=False, allow_blank=True
                         ),
                         "name": drf_serializers.CharField(
                             required=False, allow_blank=True
                         ),
                         "custom_attributes": drf_serializers.JSONField(required=False),
-                        "custom_limits": drf_serializers.JSONField(required=False),
                     },

Or if it should be supported, ensure the serializer/update logic handles it properly.

♻️ Duplicate comments (2)
src/backend/core/api/openapi.json (1)

5888-6001: Mailbox max_recipients_per_message is typed as string while representing a numeric limit

Mailbox.max_recipients_per_message is declared with "type": "string" even though it represents a numeric limit and is conceptually tied to the integer config values MAX_RECIPIENTS_PER_MESSAGE / MAX_DEFAULT_RECIPIENTS_PER_MESSAGE. This is the same concern that was raised in a previous review; just confirming it still applies.

If the serializer actually returns an integer, the OpenAPI schema should ideally use "type": "integer" (and remain readOnly: true) for consistency and better client typing. If the API intentionally returns a string (for example to allow values like "unlimited"), documenting that explicitly in a description and/or using a more constrained schema (e.g. oneOf integer or enum) would avoid confusion between config and per‑mailbox values.

You can quickly check the serializer field type with something like:

#!/bin/bash
# Inspect usages and field definition for max_recipients_per_message
rg -n "max_recipients_per_message" src/backend/core -C3 || true

Then align the OpenAPI schema with whatever type the serializer actually exposes.

src/frontend/src/features/forms/components/message-form/index.tsx (1)

237-246: Verify resolver updates when schema changes.

The schema is correctly memoized, but zodResolver(schema) is called inline in useForm(). According to the past review comment and React Hook Form documentation, the resolver itself should be memoized to ensure validation updates when the schema changes:

  const schema = useMemo(() => getMessageFormSchema(maxRecipientsLimit), [maxRecipientsLimit]);
+  const resolver = useMemo(() => zodResolver(schema), [schema]);

  const form = useForm({
-    resolver: zodResolver(schema),
+    resolver,
    mode: "onChange",
    reValidateMode: "onChange",
    shouldFocusError: false,
    defaultValues: formDefaultValues,
  });

This ensures the form validation uses the current limit when maxRecipientsLimit changes.

🧹 Nitpick comments (13)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (2)

35-35: Consider updating the TypeScript type instead of using type assertion.

The (domain as any) type assertion bypasses TypeScript's type checking. If MailDomainAdmin should include custom_limits, update the type definition in the API-generated types.

Apply this diff to remove the type assertion once the type is updated:

-      max_recipients_per_message: (domain as any)?.custom_limits?.max_recipients_per_message?.toString() ?? '',
+      max_recipients_per_message: domain.custom_limits?.max_recipients_per_message?.toString() ?? '',

48-64: Consider using react-hook-form's built-in validation.

The manual validation in onSubmit could be replaced with react-hook-form's declarative validation rules. This would move validation logic closer to the form field definition.

Example refactor:

const form = useForm<UpdateDomainFormData>({
  defaultValues: {
    max_recipients_per_message: (domain as any)?.custom_limits?.max_recipients_per_message?.toString() ?? '',
  },
});

// Then in the Input component:
<Input
  {...register('max_recipients_per_message', {
    validate: {
      positiveInteger: (value) => {
        if (value && (isNaN(Number(value)) || Number(value) < 1 || !Number.isInteger(Number(value)))) {
          return t('Please enter a valid positive integer or leave empty.');
        }
        return true;
      },
      withinMax: (value) => {
        if (value && Number(value) > MAX_RECIPIENTS_PER_MESSAGE) {
          return t('The limit cannot exceed the global maximum of {{max}} recipients.', { max: MAX_RECIPIENTS_PER_MESSAGE });
        }
        return true;
      }
    }
  })}
  // ... other props
/>
src/backend/core/api/openapi.json (3)

221-235: Config recipients limit constants look good; consider also requiring the default value

The new MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE entries under /api/v1.0/config/ are well described and correctly modeled as integers, and adding MAX_RECIPIENTS_PER_MESSAGE to the required list matches the “cannot be exceeded” semantics.

If MAX_DEFAULT_RECIPIENTS_PER_MESSAGE is also always present in the backend response (even when not explicitly configured), consider adding it to the required array so generated clients can rely on it being non‑optional; otherwise, it’s fine as is but clients should handle its absence.

Also applies to: 237-250


3005-3141: Maildomain and mailbox settings endpoints are consistent; optional doc improvements

The new PUT/PATCH /api/v1.0/maildomains/{maildomain_pk}/ endpoints and PATCH /api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/settings/ wire cleanly to the dedicated MailDomainAdminUpdate* and MailboxSettingsUpdate* schemas and clearly scope changes to custom_limits, which matches the PR intent.

If you want richer API docs, you could later mirror neighboring endpoints by adding explicit 400/403/404 responses for these operations, but that’s not strictly required for correctness.

Also applies to: 3669-3735


5691-5813: custom_limits surface is coherent; consider tightening schema shape for typed clients

The addition of custom_limits across MailDomainAdmin, MailDomainAdminUpdate*, MailboxAdmin, MailboxAdminUpdateMetadataRequest, MailboxSettingsUpdate*, and related endpoints forms a consistent story for domain/mailbox‑level overrides, and the descriptions make their purpose (e.g. max recipients per message) clear.

Right now custom_limits is intentionally untyped in OpenAPI (no type/nested properties), which keeps it flexible but means generated clients will treat it as any/unstructured JSON. If you expect clients to rely on specific keys like max_recipients_per_message, it may be worth, in a follow‑up, declaring at least a minimal structure (e.g. "type": "object", additionalProperties, and/or an example such as {"max_recipients_per_message": 50}) to improve discoverability and typing while still leaving room for future limits.

Also applies to: 5815-5887, 6199-6219, 6222-6225, 6345-6356, 6392-6409, 7111-7119, 7142-7150

src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (2)

72-72: Consider improving type safety instead of casting to any.

Casting useConfig() to any bypasses TypeScript's type checking. Consider either:

  1. Updating the useConfig() return type to include SCHEMA_CUSTOM_ATTRIBUTES_USER, or
  2. Using a more specific type assertion if the config shape is known.

Apply this pattern to improve type safety:

-  const { SCHEMA_CUSTOM_ATTRIBUTES_USER } = useConfig() as any;
+  const config = useConfig();
+  const SCHEMA_CUSTOM_ATTRIBUTES_USER = config.SCHEMA_CUSTOM_ATTRIBUTES_USER as JSONSchema.Schema;

124-126: Type casting pattern suggests a need for better type definitions.

The repeated pattern of casting Object.entries(customAttributes) as [string, ItemJsonSchema][] indicates that the type definitions for SCHEMA_CUSTOM_ATTRIBUTES_USER?.properties could be improved. While this works, consider defining a proper type for properties to avoid the need for manual casts.

As per coding guidelines and based on learnings, SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN defaults to an empty object rather than being undefined, so the null checks with ?? are appropriate here.

Also applies to: 135-135

src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1)

35-35: Type casting suggests incomplete type definition.

The cast (mailbox as any)?.custom_limits bypasses type checking. The MailboxAdmin interface should include proper typing for custom_limits to avoid this cast. Consider updating the type definition or using a more specific type assertion.

-      max_recipients_per_message: (mailbox as any)?.custom_limits?.max_recipients_per_message?.toString() ?? '',
+      max_recipients_per_message: mailbox.custom_limits?.max_recipients_per_message?.toString() ?? '',

This would work if MailboxAdmin.custom_limits is properly typed as { max_recipients_per_message?: number }.

src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (1)

218-225: Type assertion as any suggests missing type definition.

The (editedMailbox as any).updated_at cast bypasses type safety. If updated_at is a valid property of MailboxAdmin, it should be defined in the type. If it's not available, using it in the key could cause issues.

Consider verifying that updated_at exists in the MailboxAdmin type from the generated API types. If it does exist, remove the as any cast:

- key={`${editedMailbox.id}-${(editedMailbox as any).updated_at}`}
+ key={`${editedMailbox.id}-${editedMailbox.updated_at}`}

If updated_at is not part of the type but is returned by the API, the type definition in the API generation should be updated.

src/backend/core/tests/api/test_admin_maildomains_update.py (3)

9-10: Minor: Redundant import.

enums is imported from core on line 9 and MailDomainAccessRoleChoices is imported from core.enums on line 10. You could use just one import style consistently.

-from core import enums, factories
-from core.enums import MailDomainAccessRoleChoices
+from core import factories
+from core.enums import MailDomainAccessRoleChoices, MailboxRoleChoices

Then update line 315 to use MailboxRoleChoices.ADMIN instead of enums.MailboxRoleChoices.ADMIN.


50-52: Assertion could be more explicit.

The assertion != 50 verifies the limit wasn't set, but doesn't verify what the actual value is. If custom_limits defaults to an empty dict or None, a more explicit assertion would be clearer.

         # Verify the limit was NOT updated
         maildomain.refresh_from_db()
-        assert maildomain.custom_limits.get("max_recipients_per_message") != 50
+        assert maildomain.custom_limits.get("max_recipients_per_message") is None

This makes the test intent clearer - the limit should not have been set at all.


242-245: Specify explicit role for MailboxAccessFactory to improve test clarity.

The MailboxAccessFactory uses FuzzyChoice to randomly select from available roles each time. While this doesn't affect the test's assertion (which validates limit priority, not permissions), explicit role specification improves test determinism and clarity. Consider adding role=MailboxRoleChoices.ADMIN (or another appropriate role) to match the explicit MailDomainAccessFactory definition above it.

src/backend/core/api/serializers.py (1)

976-991: Consider additional validation for edge cases.

The mixin validates that max_recipients_per_message doesn't exceed the global maximum, but doesn't validate:

  1. Negative values (e.g., -5)
  2. Zero values (which may be invalid depending on business logic)
     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 a positive integer.")
+                    )
                 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

This aligns with the model's get_max_recipients_per_message() which checks value > 0.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bc5799f and c2ce499.

⛔ Files ignored due to path filters (17)
  • src/frontend/src/features/api/gen/maildomains/maildomains.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_create.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_settings_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts is excluded by !**/gen/**
📒 Files selected for processing (30)
  • src/backend/core/api/openapi.json (18 hunks)
  • src/backend/core/api/permissions.py (1 hunks)
  • src/backend/core/api/serializers.py (9 hunks)
  • src/backend/core/api/viewsets/config.py (4 hunks)
  • src/backend/core/api/viewsets/maildomain.py (5 hunks)
  • src/backend/core/enums.py (2 hunks)
  • src/backend/core/mda/draft.py (3 hunks)
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py (1 hunks)
  • src/backend/core/models.py (9 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_list.py (3 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_update.py (1 hunks)
  • src/backend/core/tests/api/test_config.py (2 hunks)
  • src/backend/core/tests/api/test_mailboxes.py (4 hunks)
  • src/backend/core/tests/api/test_messages_create.py (2 hunks)
  • src/backend/core/tests/models/test_mailbox.py (6 hunks)
  • src/backend/core/tests/models/test_maildomain.py (2 hunks)
  • src/backend/messages/settings.py (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients.spec.ts (1 hunks)
  • src/frontend/src/features/forms/components/message-form/index.tsx (10 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (7 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (4 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1 hunks)
  • src/frontend/src/features/providers/config.tsx (1 hunks)
  • src/frontend/src/hooks/use-ability.ts (3 hunks)
  • src/frontend/src/pages/domain/index.tsx (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (11)
  • src/frontend/src/features/providers/config.tsx
  • src/backend/core/mda/draft.py
  • src/backend/core/enums.py
  • src/backend/core/tests/api/test_config.py
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx
  • src/e2e/src/tests/message-max-recipients.spec.ts
  • src/backend/messages/settings.py
  • src/e2e/src/tests/message-max-recipients-admin.spec.ts
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
🧰 Additional context used
📓 Path-based instructions (5)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/backend/core/api/serializers.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/models/test_mailbox.py
  • src/backend/core/api/viewsets/maildomain.py
  • src/backend/core/api/permissions.py
  • src/backend/core/models.py
  • src/backend/core/tests/models/test_maildomain.py
  • src/backend/core/tests/api/test_admin_maildomains_update.py
src/backend/**/{tests.py,tests/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/{tests.py,tests/**/*.py}: Use Django’s testing tools (pytest-django) to ensure code quality and reliability
Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases

Files:

  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/models/test_mailbox.py
  • src/backend/core/tests/models/test_maildomain.py
  • src/backend/core/tests/api/test_admin_maildomains_update.py
src/backend/{templates/**/*.html,**/serializers.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Use Django templates for HTML and DRF serializers for JSON responses

Files:

  • src/backend/core/api/serializers.py
src/backend/**/{models.py,forms.py,views.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Keep business logic in models and forms; keep views thin and focused on request handling

Files:

  • src/backend/core/models.py
src/backend/**/{models.py,migrations/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Implement database indexing and query optimization (Model Meta indexes, constraints)

Files:

  • src/backend/core/models.py
🧠 Learnings (3)
📚 Learning: 2025-09-02T12:42:29.083Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/pages/domain/index.tsx:0-0
Timestamp: 2025-09-02T12:42:29.083Z
Learning: The domain admin pages in src/frontend/src/pages/domain/ use AdminLayout, not the main layout. Toast notifications for admin pages should be handled via a Toaster instance in the AdminLayout component.

Applied to files:

  • src/frontend/src/pages/domain/index.tsx
📚 Learning: 2025-09-02T12:20:22.006Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/features/layouts/components/admin/modal-create-domain/index.tsx:32-37
Timestamp: 2025-09-02T12:20:22.006Z
Learning: In the messages application, `SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN` from the config defaults to an empty object `{}` rather than being potentially undefined, so null checks are not needed when using this configuration value.

Applied to files:

  • src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx
  • src/backend/core/api/openapi.json
  • src/frontend/src/features/forms/components/message-form/index.tsx
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Use Django REST Framework viewsets for API endpoints

Applied to files:

  • src/backend/core/api/viewsets/maildomain.py
🧬 Code graph analysis (13)
src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2)
src/frontend/src/features/forms/components/combobox/index.tsx (2)
  • ComboBoxProps (8-15)
  • ComboBox (17-230)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (5)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (14-39)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • useMaildomainsMailboxesSettingsUpdate (1869-1901)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/frontend/src/pages/domain/index.tsx (4)
src/frontend/src/hooks/use-ability.ts (1)
  • Abilities (34-39)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • getMaildomainsListQueryOptions (102-131)
src/frontend/src/features/api/gen/models/mail_domain_admin.ts (1)
  • MailDomainAdmin (13-26)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1)
  • ModalUpdateDomain (24-153)
src/backend/core/api/serializers.py (2)
src/backend/core/models.py (3)
  • Mailbox (483-764)
  • get_max_recipients_per_message (735-764)
  • MailDomain (243-426)
src/backend/core/api/viewsets/config.py (1)
  • get (133-181)
src/backend/core/tests/api/test_messages_create.py (3)
src/backend/core/tests/api/conftest.py (1)
  • mailbox (59-61)
src/backend/core/factories.py (3)
  • MailboxAccessFactory (99-109)
  • MailboxFactory (60-96)
  • MailDomainFactory (51-57)
src/backend/core/enums.py (1)
  • MailboxRoleChoices (15-21)
src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/forms/components/zod-json-schema-serializer/index.ts (1)
  • ItemJsonSchema (4-8)
src/backend/core/api/viewsets/maildomain.py (2)
src/backend/core/api/permissions.py (1)
  • CanManageSettings (455-482)
src/backend/core/api/serializers.py (3)
  • MailDomainAdminWriteSerializer (959-973)
  • MailDomainAdminUpdateSerializer (994-1005)
  • MailboxSettingsUpdateSerializer (1008-1016)
src/backend/core/api/permissions.py (3)
src/backend/core/api/serializers.py (4)
  • get_abilities (132-141)
  • get_abilities (165-167)
  • get_abilities (288-290)
  • get_abilities (910-912)
src/backend/core/models.py (5)
  • get_abilities (225-240)
  • get_abilities (351-389)
  • get_abilities (619-677)
  • MailDomain (243-426)
  • Mailbox (483-764)
src/backend/core/enums.py (2)
  • MailDomainAbilities (96-101)
  • MailboxAbilities (104-116)
src/backend/core/models.py (3)
src/backend/core/api/fields.py (1)
  • JSONField (8-25)
src/backend/core/enums.py (2)
  • MailDomainAbilities (96-101)
  • MailboxAbilities (104-116)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (292-294)
src/backend/core/tests/models/test_maildomain.py (1)
src/backend/core/models.py (3)
  • get_abilities (225-240)
  • get_abilities (351-389)
  • get_abilities (619-677)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (5)
src/frontend/src/features/api/gen/models/mail_domain_admin.ts (1)
  • MailDomainAdmin (13-26)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/fetch-api.ts (1)
  • fetchAPI (12-40)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/backend/core/tests/api/test_admin_maildomains_update.py (4)
src/backend/core/enums.py (2)
  • MailDomainAccessRoleChoices (48-51)
  • MailboxRoleChoices (15-21)
src/backend/core/factories.py (2)
  • MailDomainFactory (51-57)
  • MailboxFactory (60-96)
src/backend/core/models.py (8)
  • save (92-95)
  • save (207-210)
  • save (299-302)
  • save (530-533)
  • save (1022-1074)
  • save (1569-1572)
  • save (1825-1841)
  • get_max_recipients_per_message (735-764)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (292-294)
src/frontend/src/features/forms/components/message-form/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
🔇 Additional comments (40)
src/backend/core/api/permissions.py (1)

455-482: LGTM!

The CanManageSettings permission class is well-implemented and follows Django REST Framework conventions. It properly checks authentication, delegates to the object's get_abilities method, and handles both MailDomain and Mailbox types with appropriate fallback behavior.

src/frontend/src/hooks/use-ability.ts (1)

15-15: LGTM!

The new abilities are correctly defined and integrated into the useAbility hook, following the existing pattern for ability checks.

Also applies to: 27-27, 84-86

src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (2)

81-86: Query invalidation approach is safe.

The broad predicate matching all queries containing /api/v1.0/maildomains ensures all related queries are refreshed. While this could be more targeted (e.g., using specific query keys), the current approach is safe and straightforward.


24-152: Well-structured modal component.

The component demonstrates good practices:

  • Proper form state management with react-hook-form
  • Clear error handling and user feedback
  • Cleanup on modal close
  • Integration with translation and config systems
src/backend/core/tests/api/test_messages_create.py (6)

810-837: LGTM!

The test correctly validates that drafts exceeding the global recipient limit are rejected with a 400 error.


838-883: LGTM!

The test correctly validates that mailbox-level custom_limits override the global MAX_RECIPIENTS_PER_MESSAGE, testing both rejection and success cases.


885-934: LGTM!

The test correctly validates that maildomain-level custom_limits override the global MAX_RECIPIENTS_PER_MESSAGE, with appropriate success and failure cases.


936-986: LGTM!

The test correctly validates the precedence order where mailbox custom_limits take priority over maildomain custom_limits. This is a critical behavior to test.


988-1031: LGTM!

The test correctly validates that recipient limits are enforced when updating existing drafts, not just during creation.


1033-1078: LGTM! Past review comment addressed.

The test has been correctly renamed and all comments updated to reflect that it validates MAX_DEFAULT_RECIPIENTS_PER_MESSAGE (the default limit of 2) rather than the global maximum of 10. The test clearly demonstrates the fallback behavior when no custom limits are configured.

src/backend/core/models.py (6)

282-289: LGTM!

The custom_limits JSONField is correctly defined with appropriate defaults and help text.


387-388: LGTM!

The CAN_MANAGE_SETTINGS ability is correctly restricted to superusers for maildomains, with clear inline documentation.


511-518: LGTM!

The custom_limits JSONField on Mailbox is correctly defined, mirroring the structure on MailDomain.


530-552: LGTM!

The save() and clean() methods properly enforce validation for custom_limits:

  • Ensures full_clean() is called before saving
  • Validates the dictionary structure
  • Validates that max_recipients_per_message is a positive integer when present

619-677: LGTM!

The get_abilities() method correctly:

  • Initializes role to 0 (consistent with MailboxRoleChoices being integer-based)
  • Checks if the user is a domain admin via MailDomainAccess
  • Grants CAN_MANAGE_SETTINGS to domain admins

735-764: LGTM!

The get_max_recipients_per_message() method correctly implements the precedence hierarchy (mailbox → domain → global default) and ensures the effective limit never exceeds MAX_RECIPIENTS_PER_MESSAGE. The logic is clear and well-commented.

src/backend/core/tests/models/test_maildomain.py (1)

141-141: LGTM!

The test updates comprehensively cover the new CAN_MANAGE_SETTINGS ability:

  • Verifies it's False for users without access
  • Verifies it's False for domain admins (correctly restricted to superusers only)
  • Adds a dedicated test for superuser abilities where manage_settings is True

Also applies to: 160-177

src/backend/core/tests/api/test_admin_maildomains_list.py (1)

360-403: LGTM! Test expectations correctly reflect the new permission model.

The updated assertions and docstrings properly enforce that manage_settings is restricted to superusers for mail domains, while domain admins have this ability set to False. This aligns with the new CanManageSettings permission model introduced in the PR.

src/backend/core/tests/models/test_mailbox.py (1)

116-210: LGTM! Test expectations correctly updated for mailbox abilities.

The assertions properly enforce that manage_settings is False across all mailbox access levels (no_access, viewer, editor, admin, sender). The updated docstring and comment clarify that only domain admins can manage settings for mailboxes, which aligns with the new permission model.

src/backend/core/tests/api/test_mailboxes.py (1)

370-470: LGTM! Mailbox abilities test expectations correctly updated.

The assertions and docstrings properly reflect that manage_settings is restricted to domain admins for mailboxes. The test expectations are consistent with the new CanManageSettings permission model.

src/frontend/src/features/layouts/components/admin/modal-create-update-mailbox/index.tsx (1)

415-426: LGTM! Key change and field rendering updated appropriately.

The change from json-schema-field-${name} to json-schema-field-attr-${name} provides better semantic clarity for custom attribute fields. The removal of the text prop and reliance on state for error display is also correct.

src/frontend/src/features/forms/components/message-form/index.tsx (2)

58-102: Well-implemented dynamic recipient limit validation.

The implementation correctly:

  • Creates a dynamic schema factory with superRefine for total recipient validation
  • Tracks the max recipients limit based on the selected sender mailbox
  • Updates the limit when the sender changes
  • Re-validates recipient fields when the limit changes

The validation logic properly adds issues to all recipient fields (to, cc, bcc) when the total exceeds the limit.

Also applies to: 229-277


477-477: LGTM! keepErrors: true preserves validation state correctly.

Adding keepErrors: true to the reset() call ensures that validation messages are not lost when saving the draft, which is the correct behavior for this use case.

src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (1)

11-16: LGTM! Dynamic recipient limit handling implemented correctly.

The changes properly:

  • Accept maxRecipients as an optional prop
  • Calculate the limit from props or the selected mailbox
  • Use formState.errors for reactivity to external setError calls
  • Provide properly internationalized helper text with the maximum recipients info (addressing the past review comment)
  • Trigger validation for all recipient fields on change to update error states

The i18n translation issue from the past review has been correctly resolved using t() with interpolation.

Also applies to: 52-80

src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (2)

48-93: Well-implemented validation and update logic.

The implementation correctly:

  • Validates for empty or positive integer values
  • Enforces the global maximum limit with a clear error message
  • Uses the dedicated /settings/ endpoint for custom_limits
  • Sets null for empty values to use domain/global defaults
  • Handles success with toast notification and error with banner display

97-99: LGTM! Clear and helpful helper text.

The helper text properly informs users about the behavior (empty = use default) and the maximum allowed value, providing good UX guidance.

src/frontend/src/pages/domain/index.tsx (3)

25-25: LGTM! Clean implementation of domain settings management.

The changes properly:

  • Add a new MANAGE_SETTINGS action to the enum
  • Update the actions column width to accommodate both buttons
  • Gate each action button by its respective ability (CAN_MANAGE_MAILDOMAIN_ACCESSES, CAN_MANAGE_MAILDOMAIN_SETTINGS)
  • Render action buttons with appropriate labels and iconography

The button layout with flex-row and gap provides good visual separation between actions.

Also applies to: 65-82, 199-227


104-123: Excellent modal rendering with fresh data and proper key.

The implementation correctly:

  • Fetches fresh domain data from the list before passing to the modal (line 106)
  • Uses a composite key ${freshDomain.id}-${freshDomain.updated_at} to force re-render when data changes (line 109)
  • Calls refetchMaildomains() on success to refresh the list (line 117)
  • Properly resets state on close and success

This ensures the modal always displays current data and the list reflects updates.


32-39: LGTM! Query invalidation properly implemented.

The refetchMaildomains function correctly invalidates the maildomains list queries using the query key from getMaildomainsListQueryOptions() with exact: false to match all related queries.

src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (2)

60-63: LGTM!

The handleManageSettings handler follows the established pattern used by other handlers (handleResetPassword, handleManageAccess, handleUpdate), maintaining consistency.


263-267: LGTM!

The new "Limits" menu option is correctly positioned and the showSeparator logic properly handles the conditional presence of the reset password option.

src/backend/core/tests/api/test_admin_maildomains_update.py (3)

25-186: Comprehensive test coverage for maildomain custom_limits.

The test class covers essential scenarios: admin vs superuser permissions, clearing values, unauthorized access, non-admin users, field isolation, and global max validation. Well-structured tests following Django/pytest conventions.


189-363: Good test coverage for mailbox custom_limits.

Tests cover admin permissions, limit precedence (mailbox > domain > global), validation against global max, and permission boundaries. The tests align well with the get_max_recipients_per_message() model method logic.


250-269: Missing @override_settings decorator.

This test relies on MAX_DEFAULT_RECIPIENTS_PER_MESSAGE=100 but doesn't use @override_settings. If the global default differs, the assertion on line 258 could fail unexpectedly.

+    @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

Wait, I see the decorator is already on line 250-252. The test does have the decorator. Let me re-check...

Actually, looking more carefully at lines 250-252, the decorator IS present. My observation was incorrect.

src/backend/core/api/viewsets/maildomain.py (2)

173-179: Permission composition looks correct.

The update_settings action requires both base permissions (IsSuperUser OR IsMailDomainAdmin) AND CanManageSettings. This ensures domain admins can only update settings if they have the specific ability.


356-386: Well-structured settings update endpoint.

The update_settings action is cleanly implemented with proper OpenAPI documentation, dedicated serializer, and clear separation from the general update endpoint.

src/backend/core/api/serializers.py (4)

220-231: LGTM!

The max_recipients_per_message field is properly added as a read-only SerializerMethodField, delegating to the model's get_max_recipients_per_message() method which handles the priority logic correctly.

Also applies to: 292-294


994-1016: Clean serializer design for settings updates.

The MailDomainAdminUpdateSerializer and MailboxSettingsUpdateSerializer properly use the CustomLimitsUpdateMixin and restrict writable fields to only custom_limits, preventing unintended modifications to other fields.


1235-1240: Good documentation and implementation for excluding custom_limits from creation.

The list comprehension excludes custom_limits from creation response with a clear docstring explaining why. This enforces the design decision that limits can only be set via the dedicated /settings/ endpoint.


886-893: LGTM!

Adding custom_limits to MailDomainAdminSerializer fields allows reading the current limits in the admin view while keeping it read-only (per line 894).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/frontend/src/features/forms/components/message-form/index.tsx (1)

258-269: Consider optimizing effect dependencies.

The effect includes maxRecipientsLimit in the dependency array, but only uses it for comparison on line 266. This causes the effect to re-run whenever the limit changes, even though it only needs to run when selectedFromMailboxId, mailboxes, or config changes. While the early return prevents issues, you could optimize by removing maxRecipientsLimit from the deps and using a ref or relying on the comparison alone.

Optional refactor:

     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]);
+        setMaxRecipientsLimit(newLimit);
+    }, [selectedFromMailboxId, mailboxes, config]);

Note: setMaxRecipientsLimit will only trigger a re-render if the value actually changes (React batches state updates with the same value).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c2ce499 and d3eba79.

📒 Files selected for processing (1)
  • src/frontend/src/features/forms/components/message-form/index.tsx (10 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-02T12:20:22.006Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/features/layouts/components/admin/modal-create-domain/index.tsx:32-37
Timestamp: 2025-09-02T12:20:22.006Z
Learning: In the messages application, `SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN` from the config defaults to an empty object `{}` rather than being potentially undefined, so null checks are not needed when using this configuration value.

Applied to files:

  • src/frontend/src/features/forms/components/message-form/index.tsx
🧬 Code graph analysis (1)
src/frontend/src/features/forms/components/message-form/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
🔇 Additional comments (7)
src/frontend/src/features/forms/components/message-form/index.tsx (7)

58-102: Well-structured dynamic validation schema.

The factory function correctly enforces the total recipient limit across all fields (to + cc + bcc) using superRefine. The error messages are properly localized and applied consistently to all recipient fields when the limit is exceeded.


229-235: Robust initialization of recipient limit.

The state initialization correctly handles edge cases by falling back from the initial mailbox to the selected mailbox, and from mailbox-specific limits to config defaults. The logic ensures a valid limit is always set.


237-241: Excellent fix: resolver now updates with schema changes.

The resolver is correctly memoized using useMemo(() => zodResolver(schema), [schema]), ensuring that React Hook Form picks up validation changes whenever maxRecipientsLimit changes. This addresses the issue flagged in the previous review.


243-249: Form configuration correctly implements dynamic validation.

The form now uses the memoized resolver and mode: "onChange" for immediate validation feedback, which is appropriate for enforcing dynamic recipient limits as users type.


271-280: Effective revalidation on limit changes.

The effect correctly triggers validation only when recipients are present, preventing unnecessary validation errors on empty forms. The implementation is sound.


480-480: Good UX: preserving validation errors across draft saves.

Adding keepErrors: true ensures that recipient limit validation errors persist after auto-save, providing consistent feedback to users without clearing important warnings.


617-617: Consistent propagation of recipient limit to all fields.

The maxRecipientsLimit is correctly passed to all three recipient input components (to, cc, bcc), ensuring consistent UI behavior and limit enforcement across all recipient fields.

Also applies to: 634-634, 650-650

@sdemagny sdemagny force-pushed the sdem/recipients_limit_per_msg branch from d3eba79 to b2d5de7 Compare December 15, 2025 22:14
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/backend/core/models.py (1)

304-316: Add validation for custom_limits in MailDomain.clean().

The MailDomain.clean() method validates custom_attributes but does not validate the new custom_limits field. In contrast, Mailbox.clean() (lines 535-552) does validate its custom_limits. This inconsistency could allow invalid limit configurations at the domain level.

Consider adding similar validation:

 def clean(self):
     """Validate custom attributes and limits."""
     # Custom attributes schema
     try:
         jsonschema.validate(
             self.custom_attributes, settings.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN
         )
     except jsonschema.ValidationError as exception:
         raise ValidationError(
             {"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"}
+            )
+        if "max_recipients_per_message" in self.custom_limits:
+            value = self.custom_limits["max_recipients_per_message"]
+            if value is not None and (not isinstance(value, int) or value < 1):
+                raise ValidationError(
+                    {
+                        "custom_limits": "max_recipients_per_message must be a positive integer"
+                    }
+                )
+
     super().clean()
♻️ Duplicate comments (2)
src/frontend/src/features/forms/components/message-form/index.tsx (1)

29-29: Remove unused import useConfigRetrieve.

This was flagged in a previous review and appears to still be present. The import is not used anywhere in the file.

 import { useConfig } from "@/features/providers/config";
-import { useConfigRetrieve } from "@/features/api/gen/config/config";
src/backend/core/api/openapi.json (1)

5888-6000: Mailbox.max_recipients_per_message: string type vs numeric semantics

max_recipients_per_message is declared as "type": "string" even though it represents a numeric limit and mirrors the integer-valued MAX_RECIPIENTS_PER_MESSAGE in /config/. If the serializer returns an integer, consider changing this to "type": "integer" (keeping readOnly: true) so generated clients get correct typing; if it really is a formatted string (e.g. "unlimited"), it would help to document that explicitly in the field description to avoid confusion.

🧹 Nitpick comments (5)
src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (1)

52-54: Consider removing unused fieldState destructuring.

fieldState is destructured but not used since fieldError is computed from formState.errors directly. This is intentional per the comment, but the unused variable could be removed for clarity.

-            render={({ field, fieldState }) => {
+            render={({ field }) => {
src/backend/core/models.py (1)

735-764: LGTM with minor simplification opportunity.

The get_max_recipients_per_message method correctly implements the priority chain (mailbox → domain → global default) and properly caps values with min() to respect the global maximum.

The hasattr checks on lines 745 and 751-754 are redundant since custom_limits is a JSONField with default=dict, guaranteeing the attribute exists. However, this is defensive coding and doesn't affect correctness.

src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1)

117-127: Enhance UX: Add step="1" to number input.

The number input currently allows decimal entry in the browser UI, though validation will catch non-integers. Adding step="1" provides a better user experience by:

  • Showing integer-only spinners in supporting browsers
  • Hinting that only whole numbers are expected

Apply this diff:

             <Input
               {...register('max_recipients_per_message')}
               label={t('Maximum recipients per message')}
               type="number"
+              step="1"
               min={1}
               max={MAX_RECIPIENTS_PER_MESSAGE}
               fullWidth
               state={errors.max_recipients_per_message ? 'error' : 'default'}
               text={helperText}
               value={maxRecipientsValue}
             />
src/backend/core/api/openapi.json (2)

221-250: Config: new recipient limits fields look good; consider also requiring the default value

MAX_RECIPIENTS_PER_MESSAGE / MAX_DEFAULT_RECIPIENTS_PER_MESSAGE as integers with clear descriptions align well with the feature. If MAX_DEFAULT_RECIPIENTS_PER_MESSAGE is always present in the response (even when using global defaults), you might want to add it to the required list alongside MAX_RECIPIENTS_PER_MESSAGE so clients can rely on it being defined.


6199-6209: MailboxAdmin.custom_limits exposure matches admin use; consider marking it readOnly

Adding custom_limits on MailboxAdmin gives the admin UI the effective limits alongside other metadata. Since updates go through MailboxAdminUpdateMetadataRequest and the /settings/ endpoint rather than this schema, you might optionally mark custom_limits as readOnly: true here to make it explicit that it’s not writable at this level in generated clients.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d3eba79 and b2d5de7.

⛔ Files ignored due to path filters (17)
  • src/frontend/src/features/api/gen/maildomains/maildomains.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_create.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_settings_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts is excluded by !**/gen/**
📒 Files selected for processing (29)
  • src/backend/core/api/openapi.json (18 hunks)
  • src/backend/core/api/permissions.py (1 hunks)
  • src/backend/core/api/serializers.py (9 hunks)
  • src/backend/core/api/viewsets/config.py (4 hunks)
  • src/backend/core/api/viewsets/maildomain.py (5 hunks)
  • src/backend/core/enums.py (2 hunks)
  • src/backend/core/mda/draft.py (3 hunks)
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py (1 hunks)
  • src/backend/core/models.py (9 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_list.py (3 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_update.py (1 hunks)
  • src/backend/core/tests/api/test_config.py (2 hunks)
  • src/backend/core/tests/api/test_mailboxes.py (4 hunks)
  • src/backend/core/tests/api/test_messages_create.py (2 hunks)
  • src/backend/core/tests/models/test_mailbox.py (6 hunks)
  • src/backend/core/tests/models/test_maildomain.py (2 hunks)
  • src/backend/messages/settings.py (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients.spec.ts (1 hunks)
  • src/frontend/src/features/forms/components/message-form/index.tsx (10 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (7 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1 hunks)
  • src/frontend/src/features/providers/config.tsx (1 hunks)
  • src/frontend/src/hooks/use-ability.ts (3 hunks)
  • src/frontend/src/pages/domain/index.tsx (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (15)
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx
  • src/frontend/src/hooks/use-ability.ts
  • src/e2e/src/tests/message-max-recipients.spec.ts
  • src/backend/messages/settings.py
  • src/backend/core/tests/api/test_admin_maildomains_update.py
  • src/backend/core/api/permissions.py
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/enums.py
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/tests/models/test_mailbox.py
  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/e2e/src/tests/message-max-recipients-admin.spec.ts
  • src/backend/core/mda/draft.py
  • src/frontend/src/features/providers/config.tsx
🧰 Additional context used
📓 Path-based instructions (5)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/tests/models/test_maildomain.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/api/viewsets/maildomain.py
  • src/backend/core/tests/api/test_config.py
  • src/backend/core/api/serializers.py
  • src/backend/core/models.py
src/backend/**/{tests.py,tests/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/{tests.py,tests/**/*.py}: Use Django’s testing tools (pytest-django) to ensure code quality and reliability
Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases

Files:

  • src/backend/core/tests/models/test_maildomain.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/api/test_config.py
src/backend/{templates/**/*.html,**/serializers.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Use Django templates for HTML and DRF serializers for JSON responses

Files:

  • src/backend/core/api/serializers.py
src/backend/**/{models.py,forms.py,views.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Keep business logic in models and forms; keep views thin and focused on request handling

Files:

  • src/backend/core/models.py
src/backend/**/{models.py,migrations/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Implement database indexing and query optimization (Model Meta indexes, constraints)

Files:

  • src/backend/core/models.py
🧠 Learnings (3)
📚 Learning: 2025-09-02T12:42:29.083Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/pages/domain/index.tsx:0-0
Timestamp: 2025-09-02T12:42:29.083Z
Learning: The domain admin pages in src/frontend/src/pages/domain/ use AdminLayout, not the main layout. Toast notifications for admin pages should be handled via a Toaster instance in the AdminLayout component.

Applied to files:

  • src/frontend/src/pages/domain/index.tsx
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Use Django REST Framework viewsets for API endpoints

Applied to files:

  • src/backend/core/api/viewsets/maildomain.py
📚 Learning: 2025-09-02T12:20:22.006Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/features/layouts/components/admin/modal-create-domain/index.tsx:32-37
Timestamp: 2025-09-02T12:20:22.006Z
Learning: In the messages application, `SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN` from the config defaults to an empty object `{}` rather than being potentially undefined, so null checks are not needed when using this configuration value.

Applied to files:

  • src/frontend/src/features/forms/components/message-form/index.tsx
  • src/backend/core/api/openapi.json
🧬 Code graph analysis (8)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (6)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (14-39)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • useMaildomainsMailboxesSettingsUpdate (1869-1901)
src/frontend/src/features/utils/task-import-cache/index.ts (1)
  • value (20-23)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/backend/core/tests/models/test_maildomain.py (1)
src/backend/core/models.py (3)
  • get_abilities (225-240)
  • get_abilities (351-389)
  • get_abilities (619-677)
src/backend/core/api/viewsets/maildomain.py (4)
src/backend/core/api/permissions.py (1)
  • CanManageSettings (455-482)
src/backend/core/api/viewsets/maildomain_access.py (1)
  • get_serializer_class (34-38)
src/backend/core/api/viewsets/mailbox_access.py (1)
  • get_serializer_class (38-42)
src/backend/core/api/serializers.py (2)
  • MailDomainAdminUpdateSerializer (994-1005)
  • MailboxSettingsUpdateSerializer (1008-1016)
src/frontend/src/features/forms/components/message-form/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx (3)
src/frontend/src/features/providers/admin-maildomain.tsx (1)
  • useAdminMailDomain (47-53)
src/frontend/src/hooks/use-ability.ts (1)
  • Abilities (34-39)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1)
  • ModalUpdateDomain (24-153)
src/backend/core/api/serializers.py (1)
src/backend/core/models.py (3)
  • Mailbox (483-764)
  • get_max_recipients_per_message (735-764)
  • MailDomain (243-426)
src/backend/core/models.py (3)
src/backend/core/api/fields.py (1)
  • JSONField (8-25)
src/backend/core/enums.py (3)
  • MailDomainAbilities (96-101)
  • MailDomainAccessRoleChoices (48-51)
  • MailboxAbilities (104-116)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (292-294)
src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (2)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (14-39)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx (1)
  • ModalMailboxManageLimits (24-143)
🔇 Additional comments (17)
src/backend/core/tests/api/test_config.py (1)

30-31: LGTM!

The test correctly validates that the new MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE configuration values are exposed via the /api/v1.0/config/ endpoint for both authenticated and unauthenticated users.

Also applies to: 56-57

src/frontend/src/features/forms/components/message-form/index.tsx (3)

271-280: LGTM!

The revalidation effect correctly triggers validation of recipient fields when the max limit changes. The form object from useForm is stable, so including it in the dependency array is safe.


58-102: LGTM!

The schema factory correctly implements cross-field validation for recipient limits using superRefine. The logic properly:

  • Short-circuits when no limit is configured
  • Calculates total recipients across to/cc/bcc fields
  • Adds validation issues only to fields that have values (except to which always gets the error)
  • Uses i18n for the error message with proper interpolation

229-269: LGTM!

The recipient limit state initialization and update logic correctly:

  • Prioritizes mailbox-specific limits over global defaults
  • Falls back through config defaults consistently
  • Updates the limit when the sender mailbox changes
  • Properly converts values to numbers
src/backend/core/models.py (1)

535-552: LGTM!

The Mailbox.clean() method properly validates the custom_limits structure:

  • Ensures it's a dictionary
  • Validates max_recipients_per_message is a positive integer when present
  • Provides clear error messages

As per coding guidelines, this follows Django best practices by keeping validation logic in the model.

src/backend/core/tests/models/test_maildomain.py (2)

141-161: LGTM!

The updated tests correctly verify that:

  • Users with no access have manage_settings=False
  • Domain admins have manage_settings=False (only superusers can manage domain settings per the implementation)

This aligns with the comment in models.py line 387-388: "For now, only superusers can manage settings for maildomains."


163-177: Test correctly reflects implementation, but superuser abilities design warrants review.

The test accurately asserts the current behavior: a superuser without an explicit domain role has get=False but can manage the domain (patch/put/post/delete/manage_accesses/manage_mailboxes/manage_settings). This matches the implementation where CAN_READ: bool(role) depends only on having a role, while all other abilities depend on is_admin (which includes superuser status).

However, this design is unusual—superusers can modify and delete a domain but cannot read it. While this behavior is implemented and tested, it lacks explicit documentation explaining the intent. Consider adding a comment in MailDomain.get_abilities() to clarify whether this is intentional or if CAN_READ should also respect superuser status for consistency.

src/backend/core/api/openapi.json (10)

3005-3141: Maildomain PUT/PATCH endpoints are coherent with custom_limits-only updates

The new PUT/PATCH operations on /api/v1.0/maildomains/{maildomain_pk}/ using MailDomainAdminUpdate*/PatchedMailDomainAdminUpdateRequest cleanly scope updates to custom_limits and keep the admin read model (MailDomainAdmin) separate. This shape looks consistent and future-proof.


3669-3735: Mailbox settings endpoint design matches the new custom_limits model

PATCH /api/v1.0/maildomains/{maildomain_pk}/mailboxes/{id}/settings/ with PatchedMailboxSettingsUpdateRequestMailboxSettingsUpdate is a clear, narrowly-scoped settings endpoint and lines up with the new manage_settings ability. No OpenAPI issues spotted.


5692-5785: MailDomainAdmin: exposing custom_limits + manage_settings ability is consistent

Adding custom_limits (readOnly + required) and the manage_settings flag under abilities accurately reflects the new per-domain limits and permissions. This keeps the admin surface self-describing for the UI.


5787-5813: MailDomainAdminUpdate DTOs cleanly isolate the settings surface*

MailDomainAdminUpdate / MailDomainAdminUpdateRequest focusing on id + custom_limits (and the patched variant) are a good way to constrain what can be written while still letting the admin read model stay richer. The schema wiring to the new PUT/PATCH endpoints looks correct.


5815-5865: MailDomainAdminWrite description tweak improves clarity*

The updated description on MailDomainAdminWrite / MailDomainAdminWriteRequest (“creating admin view”) better explains their role and matches the existing create endpoint behavior; nothing problematic here.


6222-6303: MailboxAdminCreate description correctly documents that custom_limits is not settable

The extended description on MailboxAdminCreate (noting custom_limits is excluded at creation and only modifiable via /settings/) aligns with the API design and should help prevent misuse by client implementers.


6345-6356: MailboxAdminUpdateMetadataRequest: metadata.custom_limits hook is aligned with settings model

Adding custom_attributes and custom_limits into MailboxAdminUpdateMetadataRequest gives a clear place for mailbox-level overrides while keeping the top-level MailboxAdmin shape stable. This fits well with the new admin flows for per-mailbox limits.


6392-6409: MailboxSettingsUpdate schema is minimal and appropriate for the /settings/ endpoint

MailboxSettingsUpdate (id + custom_limits) is a sensible read model for the /settings/ PATCH response, mirroring the domain-level update DTO pattern. Schema and required fields look correct.


7111-7119: PatchedMailDomainAdminUpdateRequest correctly mirrors the non-patched variant

The patched request schema for domain settings mirrors MailDomainAdminUpdateRequest while allowing partial updates, which is exactly what the PATCH operation needs. No issues spotted.


7142-7150: PatchedMailboxSettingsUpdateRequest is consistent with MailboxSettingsUpdate

The patched mailbox settings request shape (only custom_limits, all optional) aligns with the non-patched MailboxSettingsUpdate schema and the PATCH semantics of the /settings/ endpoint.

Comment on lines 66 to 93
try {
// Use the dedicated /settings/ endpoint for custom_limits
await updateMailboxSettings({
maildomainPk: domainId,
id: mailbox.id,
data: {
custom_limits: {
max_recipients_per_message: value === '' ? null : Number(value),
},
},
});

onSuccess();
addToast(
<ToasterItem type="info">
<Icon name="check" />
<span>{t('The mailbox limits have been updated!')}</span>
</ToasterItem>, {
toastId: "toast_edit_mailbox_limits_success",
}
);
handleClose();

} catch (err: any) {
const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox limits.');
setError(errorMessage);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Missing state reset in error path.

When the API call fails, isSubmitting is never reset to false, leaving the Save button permanently disabled. Users cannot retry after an error.

Apply this diff to reset the submitting state in the catch block:

     } catch (err: any) {
       const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox limits.');
       setError(errorMessage);
+      setIsSubmitting(false);
     }

Alternatively, use a finally block:

     } catch (err: any) {
       const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox limits.');
       setError(errorMessage);
+    } finally {
+      setIsSubmitting(false);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
// Use the dedicated /settings/ endpoint for custom_limits
await updateMailboxSettings({
maildomainPk: domainId,
id: mailbox.id,
data: {
custom_limits: {
max_recipients_per_message: value === '' ? null : Number(value),
},
},
});
onSuccess();
addToast(
<ToasterItem type="info">
<Icon name="check" />
<span>{t('The mailbox limits have been updated!')}</span>
</ToasterItem>, {
toastId: "toast_edit_mailbox_limits_success",
}
);
handleClose();
} catch (err: any) {
const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox limits.');
setError(errorMessage);
}
};
try {
// Use the dedicated /settings/ endpoint for custom_limits
await updateMailboxSettings({
maildomainPk: domainId,
id: mailbox.id,
data: {
custom_limits: {
max_recipients_per_message: value === '' ? null : Number(value),
},
},
});
onSuccess();
addToast(
<ToasterItem type="info">
<Icon name="check" />
<span>{t('The mailbox limits have been updated!')}</span>
</ToasterItem>, {
toastId: "toast_edit_mailbox_limits_success",
}
);
handleClose();
} catch (err: any) {
const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox limits.');
setError(errorMessage);
setIsSubmitting(false);
}
};
Suggested change
try {
// Use the dedicated /settings/ endpoint for custom_limits
await updateMailboxSettings({
maildomainPk: domainId,
id: mailbox.id,
data: {
custom_limits: {
max_recipients_per_message: value === '' ? null : Number(value),
},
},
});
onSuccess();
addToast(
<ToasterItem type="info">
<Icon name="check" />
<span>{t('The mailbox limits have been updated!')}</span>
</ToasterItem>, {
toastId: "toast_edit_mailbox_limits_success",
}
);
handleClose();
} catch (err: any) {
const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox limits.');
setError(errorMessage);
}
};
try {
// Use the dedicated /settings/ endpoint for custom_limits
await updateMailboxSettings({
maildomainPk: domainId,
id: mailbox.id,
data: {
custom_limits: {
max_recipients_per_message: value === '' ? null : Number(value),
},
},
});
onSuccess();
addToast(
<ToasterItem type="info">
<Icon name="check" />
<span>{t('The mailbox limits have been updated!')}</span>
</ToasterItem>, {
toastId: "toast_edit_mailbox_limits_success",
}
);
handleClose();
} catch (err: any) {
const errorMessage = err?.data?.custom_limits?.[0] || err?.data?.detail || t('An error occurred while updating the mailbox limits.');
setError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
🤖 Prompt for AI Agents
In
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-limits/index.tsx
around lines 66 to 93, the catch path never resets the submitting state so the
Save button remains disabled after an error; update the error path to call
setIsSubmitting(false) (or better, move the reset into a finally block) so
isSubmitting is always cleared after the API call, and keep the existing
setError logic intact.

@sdemagny sdemagny force-pushed the sdem/recipients_limit_per_msg branch from b2d5de7 to 0014c47 Compare December 15, 2025 22:59
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (1)

31-31: Past review comment about missing state reset is incorrect.

A previous review comment flagged that isSubmitting is never reset to false in the catch block (lines 89-92). However, isSubmitting is actually derived from React Query's isPending state, which automatically resets when the mutation completes (success or failure). No manual state management is needed here.

🧹 Nitpick comments (16)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (2)

2-2: Add missing semicolon for consistency.

The import statement on line 2 is missing a semicolon, while all other imports have them. This inconsistency could cause issues with certain build tools or code formatters.

Apply this diff:

-import React, { useState } from 'react'
+import React, { useState } from 'react';

110-114: Verify translation handling for error messages.

The error message is wrapped in t() at line 112, but errorMessage is constructed at line 90 and may already include translated strings (when it comes from manual validation) or raw API error messages. While this defensive approach ensures API errors are passed through, it could cause issues if translation keys are accidentally double-translated.

Consider removing the t() wrapper since error messages are already handled appropriately in the construction:

-            <Banner type="error">
-              {t(error)}
-            </Banner>
+            <Banner type="error">
+              {error}
+            </Banner>
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (3)

2-2: Add missing semicolon for consistency.

The import statement is missing a semicolon, while all other imports have them. This is the same issue present in the mailbox settings modal.

Apply this diff:

-import React, { useState } from 'react'
+import React, { useState } from 'react';

51-64: Consider extracting duplicated validation logic.

The validation logic (lines 51-64) is identical to the mailbox settings modal at src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (lines 52-64).

Consider extracting this into a shared validation utility function:

// utils/validate-recipient-limit.ts
export const validateRecipientLimit = (
  value: string,
  maxLimit: number,
  t: (key: string, params?: any) => string
): { isValid: boolean; error?: string } => {
  if (value !== '' && (isNaN(Number(value)) || Number(value) < 1 || !Number.isInteger(Number(value)))) {
    return { 
      isValid: false, 
      error: t('Please enter a valid positive integer or leave empty.') 
    };
  }
  
  if (value !== '' && Number(value) > maxLimit) {
    return { 
      isValid: false, 
      error: t('The limit cannot exceed the global maximum of {{max}} recipients.', { max: maxLimit })
    };
  }
  
  return { isValid: true };
};

Then use it in both modals to reduce duplication and improve maintainability.


120-124: Verify translation handling for error messages.

Similar to the mailbox settings modal, the error message is wrapped in t() at line 122, but errorMessage at line 100 may already include translated strings or raw API errors. This is the same pattern as the mailbox modal.

Consider removing the t() wrapper for consistency:

-            <Banner type="error">
-              {t(error)}
-            </Banner>
+            <Banner type="error">
+              {error}
+            </Banner>
src/backend/core/api/openapi.json (1)

3006-3142: Unify and type custom_limits across domain/mailbox schemas and settings endpoints

The new domain/mailbox surfaces are coherent (custom_limits on MailDomainAdmin/MailboxAdmin, max_recipients_per_message on Mailbox, manage_settings abilities, and the /settings/ endpoint returning MailboxSettingsUpdate). One thing that could help API consumers:

  • custom_limits is currently an untyped free‑form field in multiple places (MailDomainAdmin*, MailboxAdmin, MailboxAdminUpdateMetadataRequest, MailboxSettingsUpdate*). For client codegen and self‑documentation, consider introducing a shared schema (e.g. CustomLimits) with an explicit object shape (at least max_recipients_per_message: integer, plus nullable/bounds if applicable) and referencing it everywhere custom_limits appears instead of repeating an untyped property.

This keeps behavior unchanged but improves typing consistency and reduces drift between the various update/read models and the /settings/ path.

Also applies to: 3670-3735, 5693-5814, 5890-6001, 6145-6222, 6223-6304, 6347-6357, 6393-6410, 7112-7120, 7143-7151

src/backend/core/mda/draft.py (1)

252-274: Recipient limit validation is correct; consider consolidating count queries.

The validation logic correctly handles partial updates by preserving existing counts for fields not being updated. However, lines 256-261 execute three separate COUNT queries. Consider consolidating into a single aggregation:

-    # 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()
+    # Current counts per type (single query)
+    from django.db.models import Count, Case, When, IntegerField
+    counts = message.recipients.aggregate(
+        to=Count(Case(When(type=enums.MessageRecipientTypeChoices.TO, then=1), output_field=IntegerField())),
+        cc=Count(Case(When(type=enums.MessageRecipientTypeChoices.CC, then=1), output_field=IntegerField())),
+        bcc=Count(Case(When(type=enums.MessageRecipientTypeChoices.BCC, then=1), output_field=IntegerField())),
+    )
+    current_counts = {
+        "to": counts["to"] or 0,
+        "cc": counts["cc"] or 0,
+        "bcc": counts["bcc"] or 0,
+    }

Note: The import should be moved to the top of the file alongside existing Django imports.

src/backend/core/tests/api/test_admin_maildomains_list.py (1)

378-379: Add test verifying superuser can manage settings for maildomains.

The assertion correctly validates that domain admins cannot manage settings (lines 378-379, 402-403). However, corresponding superuser tests at lines 428-438 and 463-473 only verify HTTP status codes; they do not assert that manage_settings is True for superusers. Add an assertion in one of these superuser tests to confirm the permission is granted as expected.

src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (4)

2-2: Remove unused import.

getMailboxEmail is imported but never used in this file.

 import test, { expect } from "@playwright/test";
-import { getMailboxEmail } from "../utils";
 import { signInKeycloakIfNeeded } from "../utils-test";

51-51: Fix grammatical error in test name.

The test name "should super user can customize..." is grammatically incorrect.

-  test("should super user can customize domain max recipients per message more than the global limit", async ({ page, browserName }) => {
+  test("should prevent super user from setting domain max recipients above the global limit", async ({ page, browserName }) => {

65-68: Replace waitForTimeout with explicit waits.

Using waitForTimeout is a Playwright anti-pattern that leads to flaky tests. Consider waiting for a specific condition instead, such as waiting for the input to be visible and enabled.

-    await page.waitForTimeout(50);
     const maxRecipientsPerMessageInput = page.getByLabel("Maximum recipients per message");
-    expect(maxRecipientsPerMessageInput).toBeVisible({ timeout: 5000 });
-    console.log(maxRecipientsPerMessageInput);
+    await expect(maxRecipientsPerMessageInput).toBeVisible({ timeout: 5000 });

Also applies to: 78-79


84-95: Incomplete test with TODO.

This test is a placeholder. Consider either implementing it or removing it until it's ready.

Would you like me to help implement this test based on the patterns established in the other tests?

src/frontend/src/pages/domain/index.tsx (2)

94-103: Remove unnecessary non-null assertion.

The non-null assertion on editedDomain! (line 96) is redundant since the condition already checks that editedDomain is truthy.

       {canManageMaildomainAccesses && editedDomain && (
         <ModalMaildomainManageAccesses
-          domain={editedDomain!}
+          domain={editedDomain}
           isOpen={!!editedDomain && editAction === MailDomainEditAction.MANAGE_ACCESS}

104-123: Consider extracting fresh domain lookup.

The IIFE pattern works but is unconventional. Consider extracting the fresh domain lookup for better readability.

+      {(() => {
+        if (!editedDomain || editAction !== MailDomainEditAction.MANAGE_SETTINGS) return null;
+        const freshDomain = domains.find(d => d.id === editedDomain.id) || editedDomain;
+        return (
+          <ModalUpdateDomain
+            key={`${freshDomain.id}-${freshDomain.updated_at}`}
+            domain={freshDomain}
+            isOpen={true}
+            onClose={() => {
+              setEditedDomain(null)
+              setEditAction(null)
+            }}
+            onSuccess={() => {
+              refetchMaildomains();
+              setEditedDomain(null)
+              setEditAction(null)
+            }}
+          />
+        );
+      })()}

Alternatively, compute freshDomain once at the top of the component when editedDomain changes:

const freshDomain = editedDomain 
  ? domains.find(d => d.id === editedDomain.id) || editedDomain 
  : null;
src/backend/core/models.py (2)

282-305: Consider validating MailDomain.custom_limits similarly to mailbox limits

You’ve added MailDomain.custom_limits and updated the clean docstring to mention limits, but clean() still only validates custom_attributes. This means malformed or negative max_recipients_per_message values can be stored at the domain level (they’re later ignored by get_max_recipients_per_message, but still end up in the DB).

It would be cleaner and safer to mirror the Mailbox.clean checks here (dict type, positive integer or None for max_recipients_per_message) so domain- and mailbox-level limits have consistent validation.


619-672: Double‑check whether mailbox settings should be manageable by superusers as well

Mailbox.get_abilities now uses the annotated is_domain_admin flag and sets MailboxAbilities.CAN_MANAGE_SETTINGS based solely on that flag:

is_domain_admin = ...
MailboxAbilities.CAN_MANAGE_SETTINGS: is_domain_admin,

Given that domain‑level CAN_MANAGE_SETTINGS includes user.is_superuser, this asymmetry means a global superuser without explicit MailDomainAccess.ADMIN will not be able to manage mailbox settings via CanManageSettings, even though they can for maildomains.

If the intent is that superusers can always manage mailbox settings, consider:

MailboxAbilities.CAN_MANAGE_SETTINGS: is_domain_admin or user.is_superuser,

Otherwise, this behavior is fine but should be a deliberate choice.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b2d5de7 and 0014c47.

⛔ Files ignored due to path filters (17)
  • src/frontend/src/features/api/gen/maildomains/maildomains.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_create.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_settings_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts is excluded by !**/gen/**
📒 Files selected for processing (30)
  • src/backend/core/api/openapi.json (18 hunks)
  • src/backend/core/api/permissions.py (1 hunks)
  • src/backend/core/api/serializers.py (9 hunks)
  • src/backend/core/api/viewsets/config.py (4 hunks)
  • src/backend/core/api/viewsets/mailbox.py (3 hunks)
  • src/backend/core/api/viewsets/maildomain.py (6 hunks)
  • src/backend/core/enums.py (2 hunks)
  • src/backend/core/mda/draft.py (3 hunks)
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py (1 hunks)
  • src/backend/core/models.py (9 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_list.py (3 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_update.py (1 hunks)
  • src/backend/core/tests/api/test_config.py (2 hunks)
  • src/backend/core/tests/api/test_mailboxes.py (4 hunks)
  • src/backend/core/tests/api/test_messages_create.py (2 hunks)
  • src/backend/core/tests/models/test_mailbox.py (6 hunks)
  • src/backend/core/tests/models/test_maildomain.py (2 hunks)
  • src/backend/messages/settings.py (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients.spec.ts (1 hunks)
  • src/frontend/src/features/forms/components/message-form/index.tsx (10 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (7 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1 hunks)
  • src/frontend/src/features/providers/config.tsx (1 hunks)
  • src/frontend/src/hooks/use-ability.ts (3 hunks)
  • src/frontend/src/pages/domain/index.tsx (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx
  • src/backend/core/api/permissions.py
  • src/backend/core/api/viewsets/config.py
  • src/e2e/src/tests/message-max-recipients.spec.ts
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/models/test_mailbox.py
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
  • src/frontend/src/features/providers/config.tsx
  • src/backend/messages/settings.py
🧰 Additional context used
📓 Path-based instructions (5)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/tests/api/test_config.py
  • src/backend/core/mda/draft.py
  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/backend/core/api/viewsets/mailbox.py
  • src/backend/core/models.py
  • src/backend/core/tests/api/test_admin_maildomains_update.py
  • src/backend/core/enums.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/maildomain.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/models/test_maildomain.py
src/backend/**/{tests.py,tests/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/{tests.py,tests/**/*.py}: Use Django’s testing tools (pytest-django) to ensure code quality and reliability
Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases

Files:

  • src/backend/core/tests/api/test_config.py
  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/backend/core/tests/api/test_admin_maildomains_update.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/models/test_maildomain.py
src/backend/**/{models.py,forms.py,views.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Keep business logic in models and forms; keep views thin and focused on request handling

Files:

  • src/backend/core/models.py
src/backend/**/{models.py,migrations/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Implement database indexing and query optimization (Model Meta indexes, constraints)

Files:

  • src/backend/core/models.py
src/backend/{templates/**/*.html,**/serializers.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Use Django templates for HTML and DRF serializers for JSON responses

Files:

  • src/backend/core/api/serializers.py
🧠 Learnings (4)
📚 Learning: 2025-09-02T12:42:29.083Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/pages/domain/index.tsx:0-0
Timestamp: 2025-09-02T12:42:29.083Z
Learning: The domain admin pages in src/frontend/src/pages/domain/ use AdminLayout, not the main layout. Toast notifications for admin pages should be handled via a Toaster instance in the AdminLayout component.

Applied to files:

  • src/frontend/src/pages/domain/index.tsx
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Use Django REST Framework viewsets for API endpoints

Applied to files:

  • src/backend/core/api/viewsets/mailbox.py
  • src/backend/core/api/viewsets/maildomain.py
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Optimize related object fetching with select_related and prefetch_related

Applied to files:

  • src/backend/core/models.py
📚 Learning: 2025-09-02T12:20:22.006Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/features/layouts/components/admin/modal-create-domain/index.tsx:32-37
Timestamp: 2025-09-02T12:20:22.006Z
Learning: In the messages application, `SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN` from the config defaults to an empty object `{}` rather than being potentially undefined, so null checks are not needed when using this configuration value.

Applied to files:

  • src/backend/core/api/openapi.json
  • src/frontend/src/features/forms/components/message-form/index.tsx
🧬 Code graph analysis (13)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (5)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (14-39)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • useMaildomainsMailboxesSettingsUpdate (1869-1901)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2)
src/frontend/src/features/forms/components/combobox/index.tsx (2)
  • ComboBoxProps (8-15)
  • ComboBox (17-230)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
src/backend/core/mda/draft.py (2)
src/backend/core/models.py (1)
  • get_max_recipients_per_message (739-768)
src/backend/core/enums.py (1)
  • MessageRecipientTypeChoices (31-36)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (5)
src/frontend/src/features/api/gen/models/mail_domain_admin.ts (1)
  • MailDomainAdmin (13-26)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/fetch-api.ts (1)
  • fetchAPI (12-40)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/backend/core/api/viewsets/mailbox.py (4)
src/backend/core/models.py (1)
  • MailDomainAccess (1649-1671)
src/backend/core/tests/models/test_mailbox.py (1)
  • user (17-19)
src/backend/core/tests/models/test_maildomain.py (2)
  • user (16-18)
  • maildomain (22-24)
src/backend/core/enums.py (1)
  • MailDomainAccessRoleChoices (48-51)
src/backend/core/models.py (3)
src/backend/core/api/fields.py (1)
  • JSONField (8-25)
src/backend/core/enums.py (2)
  • MailDomainAbilities (96-101)
  • MailboxAbilities (104-116)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (293-295)
src/backend/core/tests/api/test_admin_maildomains_update.py (3)
src/backend/core/enums.py (1)
  • MailDomainAccessRoleChoices (48-51)
src/backend/core/models.py (1)
  • get_max_recipients_per_message (739-768)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (293-295)
src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (1)
src/e2e/src/utils-test.ts (1)
  • signInKeycloakIfNeeded (6-39)
src/backend/core/api/serializers.py (1)
src/backend/core/models.py (19)
  • Meta (89-90)
  • Meta (199-202)
  • Meta (291-294)
  • Meta (465-477)
  • Meta (520-525)
  • Meta (790-794)
  • Meta (833-836)
  • Meta (1016-1021)
  • Meta (1188-1192)
  • Meta (1209-1213)
  • Meta (1250-1254)
  • Meta (1325-1329)
  • Meta (1556-1568)
  • Meta (1624-1628)
  • Meta (1664-1668)
  • Meta (1718-1722)
  • Mailbox (483-768)
  • get_max_recipients_per_message (739-768)
  • MailDomain (243-426)
src/backend/core/api/viewsets/maildomain.py (4)
src/backend/core/api/permissions.py (1)
  • CanManageSettings (455-482)
src/backend/core/api/serializers.py (2)
  • MailDomainAdminUpdateSerializer (995-1006)
  • MailboxSettingsUpdateSerializer (1009-1017)
src/backend/core/api/viewsets/mailbox.py (1)
  • get_queryset (24-47)
src/backend/core/enums.py (1)
  • MailDomainAccessRoleChoices (48-51)
src/frontend/src/features/forms/components/message-form/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
src/backend/core/tests/api/test_messages_create.py (1)
src/backend/core/api/viewsets/draft.py (2)
  • post (197-231)
  • put (234-281)
src/backend/core/tests/models/test_maildomain.py (1)
src/backend/core/models.py (3)
  • get_abilities (225-240)
  • get_abilities (351-389)
  • get_abilities (619-681)
🔇 Additional comments (33)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (1)

24-143: Overall implementation looks solid.

The component follows React and React Hook Form best practices. The mutation handling, error management, and user feedback (toasts, validation messages) are all implemented correctly. The use of React Query's mutation hook properly manages the loading state.

src/frontend/src/features/forms/components/message-form/index.tsx (6)

69-100: Recipient limit validation is well-implemented.

The superRefine validation correctly:

  • Sums recipients across all three fields (to, cc, bcc)
  • Adds validation errors to each populated recipient field
  • Uses i18n for error messages
  • Guards against undefined maxRecipients with an early return

This approach ensures users see the same error message on all recipient fields when the limit is exceeded, which provides clear feedback.


228-240: Dynamic limit initialization and schema memoization are correct.

The implementation properly:

  • Initializes the limit from the form's initial sender mailbox with appropriate fallbacks
  • Memoizes the schema based on the current limit
  • Memoizes the resolver to update when the schema changes (addressing a previous review comment)

This ensures validation reflects the current mailbox's recipient limit throughout the form lifecycle.


257-279: Dynamic limit update and revalidation logic is well-designed.

The two effects work together correctly:

  1. Sender change effect (lines 257-268): Updates the limit when the user changes the sender mailbox, properly extracting the mailbox-specific limit or falling back to the default.
  2. Revalidation effect (lines 271-279): Intelligently triggers validation only when the limit changes AND recipients exist, avoiding unnecessary validation runs.

The optimization to check for existing recipients before triggering validation is a nice touch that prevents validation errors from appearing prematurely.


479-479: Preserving errors during draft save is correct.

The keepErrors: true option ensures that validation errors remain visible after the draft auto-saves. This is the right behavior because:

  • Draft saving is a background operation
  • Validation errors (like exceeding recipient limits) need to persist so users can correct them before sending
  • Without this, errors would disappear after auto-save, confusing users

This addresses a UX issue where validation feedback would be lost during the auto-save cycle.


616-616: Consistent prop passing to all recipient fields.

The maxRecipients prop is correctly passed to all three recipient input components (to, cc, bcc). This ensures the UI consistently reflects the current mailbox's recipient limit across all recipient fields.

Also applies to: 633-633, 649-649


57-101: Dynamic recipient limit implementation is comprehensive and well-executed.

The changes successfully implement dynamic recipient limits that:

  • Adapt to the selected sender mailbox
  • Provide real-time validation across all recipient fields
  • Preserve validation state through auto-save cycles
  • Integrate seamlessly with the existing form logic

The implementation follows React best practices, properly manages state and effects, and provides a good user experience by validating only when necessary.

Also applies to: 228-279

src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (2)

66-104: API call and state management are implemented correctly.

The manual state management with setIsSubmitting in try/finally is the correct pattern when using fetchAPI directly. The finally block ensures the loading state is always reset, unlike the mailbox modal which relies on React Query's automatic state management.

The query invalidation using a predicate (lines 81-86) is a pragmatic approach that ensures all maildomains-related queries are refreshed after the update.


24-153: Implementation is solid with correct state management.

The component correctly implements manual loading state management with try/finally, appropriate query invalidation, and proper error handling. The structure mirrors the mailbox settings modal, which provides consistency across the admin UI.

The main improvement opportunity is extracting the duplicated validation logic (already noted in a separate comment).

src/backend/core/api/openapi.json (1)

221-235: New recipient limit config constants are consistent and well‑modeled

MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE are exposed as integer and marked readOnly and required, matching their numeric semantics and existing config conventions (e.g. other MAX_* sizes). Descriptions clearly state global vs default behavior; no changes needed here.

Also applies to: 237-251

src/backend/core/tests/api/test_admin_maildomains_list.py (1)

402-403: Consistent with detail view test.

Same assertion pattern applied to list view, ensuring both endpoints return consistent abilities for domain admins.

src/backend/core/tests/api/test_config.py (1)

30-31: LGTM! Config test correctly covers new recipient limit settings.

The test properly validates that MAX_RECIPIENTS_PER_MESSAGE and MAX_DEFAULT_RECIPIENTS_PER_MESSAGE are exposed in the public API configuration. Values are consistent between override settings and expected response.

Also applies to: 56-57

src/backend/core/tests/models/test_maildomain.py (2)

141-141: LGTM! Correctly extends no-access test.

Adding the manage_settings assertion ensures complete coverage of the abilities response.


160-161: LGTM! Correctly validates admin cannot manage settings.

The comment clarifies the permission model: domain admins can perform CRUD and manage accesses/mailboxes, but only superusers can manage settings.

src/backend/core/mda/draft.py (2)

101-112: LGTM! Proper recipient limit enforcement on draft creation.

The validation correctly:

  1. Normalizes recipient lists to avoid None issues
  2. Sums all recipient types (to + cc + bcc)
  3. Validates against the mailbox's effective limit
  4. Provides a clear error message with actual vs. maximum counts

184-186: Correct simplification after normalization.

Since to_emails, cc_emails, and bcc_emails are normalized to empty lists at lines 102-104, the fallback or [] is no longer needed here.

src/backend/core/api/viewsets/mailbox.py (1)

37-44: LGTM! Good N+1 query optimization.

The is_domain_admin annotation pre-computes domain admin status during the initial query, avoiding repeated database hits in get_abilities(). The Exists subquery is the correct approach for boolean annotations. The implementation includes a smart try-except fallback (models.py, lines 628-636) that gracefully handles cases where the annotation may not be present, ensuring consistency across different code paths.

src/frontend/src/hooks/use-ability.ts (1)

15-16: LGTM!

The new abilities CAN_MANAGE_SETTINGS and CAN_MANAGE_MAILDOMAIN_SETTINGS are correctly added to their respective enums and properly handled in the switch statement following the existing pattern.

Also applies to: 27-28, 84-87

src/backend/core/enums.py (1)

101-101: LGTM!

The new CAN_MANAGE_SETTINGS abilities are correctly added to both MailDomainAbilities and MailboxAbilities enums following the existing Django TextChoices pattern with appropriate value/label pairs.

Also applies to: 116-116

src/frontend/src/pages/domain/index.tsx (1)

199-226: LGTM!

The ActionsCell component correctly checks both CAN_MANAGE_MAILDOMAIN_ACCESSES and CAN_MANAGE_MAILDOMAIN_SETTINGS abilities before rendering their respective buttons, and returns null early when neither permission is granted.

src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (2)

60-63: LGTM!

The handleManageSettings function and its wiring to ActionsRow follows the established pattern used for other edit actions in this component.

Also applies to: 150-151, 237-240


218-225: Remove unnecessary type assertion; updated_at is already defined in MailboxAdmin.

The MailboxAdmin type includes readonly updated_at: string, so the as any cast on line 219 is unnecessary and can be removed. TypeScript should recognize the field without the assertion.

Likely an incorrect or invalid review comment.

src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (1)

11-79: Recipient limit wiring and form error handling look solid

Using maxRecipients/selectedMailbox?.max_recipients_per_message to compute MAX_RECIPIENTS_PER_MESSAGE, surfacing it in a translated helper text, and driving ComboBox error state from formState.errors with setValue(..., { shouldDirty: true, shouldValidate: true }) plus cross-field trigger(['to', 'cc', 'bcc']) is consistent and correct for enforcing the new recipient limits in the UI. I don’t see issues with this integration.

src/backend/core/tests/api/test_messages_create.py (1)

9-1078: Recipient‑limit test coverage is thorough and consistent with model logic

The new tests cleanly exercise global limits, mailbox/domain overrides, precedence rules, and the update‑draft path (both via override_settings and custom_limits), and the expectations line up with Mailbox.get_max_recipients_per_message. I don’t see gaps or contradictions here.

src/backend/core/tests/api/test_admin_maildomains_update.py (2)

24-186: Good end‑to‑end coverage of maildomain custom_limits permissions and bounds

The domain‑level tests clearly distinguish unauthenticated, non‑admin, admin, and superuser behaviors, and also assert that custom_limits.max_recipients_per_message cannot exceed the global max. The permission and validation expectations are consistent with the new serializers, permissions, and model behavior.


132-160: The test behavior is correct and will pass. DRF 3.15.2's ModelSerializer silently ignores input fields not declared in Meta.fields by default—it does not return 400 Bad Request. The serializer will ignore the "name" field in the request and process only "custom_limits", resulting in the 200 OK response with the name unchanged as expected. No code changes are required.

Likely an incorrect or invalid review comment.

src/backend/core/models.py (2)

511-553: Mailbox custom_limits validation is consistent and defensive

The new JSONField plus clean() checks (dict type and positive integer max_recipients_per_message) provide a solid validation layer for mailbox-level limits, and align with how get_max_recipients_per_message interprets them. This is a good place to enforce structure before hitting the database.


739-768: Recipient limit resolution logic matches documented priority

get_max_recipients_per_message() correctly implements:

  1. Mailbox custom_limits.max_recipients_per_message (capped by global max)
  2. Domain custom_limits.max_recipients_per_message (also capped)
  3. Fallback to min(MAX_DEFAULT_RECIPIENTS_PER_MESSAGE, MAX_RECIPIENTS_PER_MESSAGE)

and safely ignores non‑positive or non‑int values. This aligns with the tests and prevents any configured limit from exceeding the global ceiling.

src/backend/core/api/viewsets/maildomain.py (2)

37-69: Maildomain update wiring and permissions align with new abilities

Adding UpdateModelMixin and routing update / partial_update through MailDomainAdminUpdateSerializer with CanManageSettings ensures that only superusers (as defined in MailDomain.get_abilities) can change custom_limits, while list/retrieve continue to use the broader IsSuperUser | IsMailDomainAdmin guard. This matches the intent reflected in the tests.


167-203: Mailbox settings endpoint correctly layers domain admin + settings ability

The new /settings action on AdminMailDomainMailboxViewSet:

  • Uses MailboxSettingsUpdateSerializer dedicated to custom_limits.
  • Restricts access via existing domain‑admin permissions plus CanManageSettings, which consults Mailbox.get_abilities.
  • Annotates user_role and is_domain_admin in get_queryset to avoid N+1 lookups in get_abilities.

This design cleanly separates mailbox “settings” from general mailbox updates and should scale well without obvious correctness or performance issues.

Also applies to: 375-406

src/backend/core/api/serializers.py (3)

220-231: Mailbox serializer now exposes effective max recipients cleanly

Adding max_recipients_per_message as a SerializerMethodField backed by Mailbox.get_max_recipients_per_message() is a good way to surface the resolved limit (mailbox/domain/global) to clients without duplicating logic in the API layer. The schema annotation via extend_schema_field is also appropriate.

Also applies to: 292-296


885-895: Custom‑limits serializers and mixin are consistent with model behavior

  • MailDomainAdminSerializer now includes read‑only custom_limits, which matches its use for admin listing/detail.
  • CustomLimitsUpdateMixin.validate_custom_limits enforces that max_recipients_per_message never exceeds settings.MAX_RECIPIENTS_PER_MESSAGE, aligning with the model helper’s global cap and the new tests.
  • MailDomainAdminUpdateSerializer and MailboxSettingsUpdateSerializer cleanly scope updates to custom_limits for domains and mailboxes respectively, leaving other fields to their existing serializers.

Combined, this gives you a clear, settings‑aware update path for limits without leaking unrelated fields into these endpoints.

Also applies to: 960-975, 977-1007, 1009-1017


1051-1064: Mailbox admin serializers correctly expose and separate custom_limits and creation response

  • MailboxAdminSerializer now includes custom_limits in its fields, so admin clients can see the effective per‑mailbox limits along with other metadata.
  • MailboxAdminCreateSerializer explicitly omits custom_limits from the creation response and documents that it must be managed via the dedicated /settings/ endpoint, while still adding the one_time_password field for schema purposes.

This keeps the creation flow focused and shifts limit management to the specialized settings API, which is aligned with the new permissions and tests.

Also applies to: 1216-1223, 1234-1242

Comment on lines +163 to +177
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n -B 5 -A 15 'CRUDAbilities.CAN_READ' src/backend/core/models.py

Repository: suitenumerique/messages

Length of output: 2118


🏁 Script executed:

rg -n -B 3 -A 10 'is_superuser.*True' src/backend/core/tests/models/test_maildomain.py | head -60

Repository: suitenumerique/messages

Length of output: 636


CAN_READ ability should check superuser status for consistency.

Line 170's abilities["get"] is False for a superuser is unintentional. The get_abilities implementation sets CAN_READ = bool(role), which ignores superuser status, while all write operations and CAN_MANAGE_SETTINGS check user.is_superuser. This creates an inconsistency where a superuser can modify/delete a domain but cannot read it. Update CAN_READ to also check superuser status: CAN_READ: bool(role) or user.is_superuser.

🤖 Prompt for AI Agents
In src/backend/core/tests/models/test_maildomain.py around lines 163 to 177, the
test asserts that a superuser has no read ability which is inconsistent with
implementation: update the expectation so CAN_READ accounts for superuser
status; change the assertion to expect that the read ability is True for a
superuser by adjusting the test to reflect CAN_READ = bool(role) or
user.is_superuser.

Comment on lines 46 to 48
console.log(limitMatch);
console.log(limit);
expect(limit).toBe(100);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove debug console.log statements.

These debug statements should be removed before merging.

     // 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;
-    console.log(limitMatch);
-    console.log(limit);
     expect(limit).toBe(100);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log(limitMatch);
console.log(limit);
expect(limit).toBe(100);
// 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(100);
🤖 Prompt for AI Agents
In src/e2e/src/__tests__/message-max-recipients-admin.spec.ts around lines 46 to
48, remove the two debug console.log statements (console.log(limitMatch);
console.log(limit);) so the test contains only assertions; ensure no other debug
prints remain and run the test to confirm behavior unchanged.

Comment on lines +262 to 268
},
{
label: t('Settings'),
icon: <Icon name="tune" size={IconSize.SMALL} />,
callback: onManageSettings,
showSeparator: !onResetPassword,
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how CAN_MANAGE_SETTINGS is used in the codebase
rg -n "CAN_MANAGE_SETTINGS" --type=ts --type=tsx -C3

Repository: suitenumerique/messages

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Also search for domain settings ability checks
rg -n "CAN_MANAGE_MAILDOMAIN_SETTINGS" --type=ts --type=tsx -C3

Repository: suitenumerique/messages

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Find the domain page ActionsCell component mentioned in the review
fd "ActionsCell" --type=f

Repository: suitenumerique/messages

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Examine the mailbox-data-grid file to see current permission checks
cat -n src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx | sed -n '250,280p'

Repository: suitenumerique/messages

Length of output: 1585


🏁 Script executed:

#!/bin/bash
# Search for CAN_MANAGE_SETTINGS without type restriction
rg "CAN_MANAGE_SETTINGS" -C3

Repository: suitenumerique/messages

Length of output: 6332


🏁 Script executed:

#!/bin/bash
# Search for CAN_MANAGE_MAILDOMAIN_SETTINGS
rg "CAN_MANAGE_MAILDOMAIN_SETTINGS" -C3

Repository: suitenumerique/messages

Length of output: 2882


🏁 Script executed:

#!/bin/bash
# Find domain-related ActionsCell or actions components
rg "ActionsCell" -C2

Repository: suitenumerique/messages

Length of output: 924


🏁 Script executed:

#!/bin/bash
# Look at the mailbox-data-grid component definition and props
head -50 src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx

Repository: suitenumerique/messages

Length of output: 2477


🏁 Script executed:

#!/bin/bash
# Search for onManageSettings callback to understand where it's passed from
rg "onManageSettings" -C2

Repository: suitenumerique/messages

Length of output: 3656


🏁 Script executed:

#!/bin/bash
# Get the full ActionsRow component to see what props are available
rg -n "const ActionsRow" -A 50 src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx | head -80

Repository: suitenumerique/messages

Length of output: 2395


🏁 Script executed:

#!/bin/bash
# Check how ActionsRow is called - what props are passed to it
rg -n "ActionsRow" src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx

Repository: suitenumerique/messages

Length of output: 292


🏁 Script executed:

#!/bin/bash
# Get the ActionsRowProps type definition and the place where ActionsRow is called
cat -n src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx | sed -n '232,239p'

Repository: suitenumerique/messages

Length of output: 303


🏁 Script executed:

#!/bin/bash
# Get the section where ActionsRow is rendered (line 145 area)
cat -n src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx | sed -n '140,160p'

Repository: suitenumerique/messages

Length of output: 1051


🏁 Script executed:

#!/bin/bash
# Check the full component context - what abilities are checked at the component level
cat -n src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx | sed -n '25,80p'

Repository: suitenumerique/messages

Length of output: 2888


Add ability check for Settings action to match domain page pattern.

The Settings option should check CAN_MANAGE_SETTINGS ability before being shown, consistent with how the domain page checks CAN_MANAGE_MAILDOMAIN_SETTINGS. Currently, the Settings action is always displayed when canManageMailboxes is true, regardless of the user's settings management permission. Use the existing pattern of conditionally passing callbacks (like onResetPassword) to gate the Settings option.

🤖 Prompt for AI Agents
In
src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx
around lines 262 to 268, the "Settings" action is shown whenever
canManageMailboxes is true but does not check the CAN_MANAGE_SETTINGS ability;
update it to follow the existing pattern used for other actions (e.g.,
onResetPassword) by conditionally passing the callback only when the user has
CAN_MANAGE_SETTINGS. Concretely, wrap or replace onManageSettings with a
conditional (like canManageSettings ? onManageSettings : undefined) or gate the
action using the same ability variable used elsewhere so the Settings menu item
only renders when the user has CAN_MANAGE_SETTINGS, and keep showSeparator logic
unchanged.

Define global env var to configure max recipients allowed per message
Implement frontend restrictions
Add custom_limits on mailbox and maildomain
Add playwright tests
@sdemagny sdemagny force-pushed the sdem/recipients_limit_per_msg branch from a508e39 to 2169aab Compare December 15, 2025 23:41
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/backend/core/models.py (1)

304-316: Validate custom_limits structure and values.

The docstring mentions validating "custom attributes and limits," but only custom_attributes is validated. This is inconsistent with Mailbox.clean() (lines 535-552), which validates custom_limits structure. Without validation, invalid data could be saved in custom_limits, leading to runtime errors in get_max_recipients_per_message().

Apply this diff to add custom_limits validation:

     def clean(self):
-        """Validate custom attributes and limits."""
-        # Custom attributes schema
+        """Validate custom attributes and limits."""
+        # Validate custom_attributes schema
         try:
             jsonschema.validate(
                 self.custom_attributes, settings.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN
             )
         except jsonschema.ValidationError as exception:
             raise ValidationError(
                 {"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"}
+                )
+            if "max_recipients_per_message" in self.custom_limits:
+                value = self.custom_limits["max_recipients_per_message"]
+                if value is not None and (not isinstance(value, int) or value < 1):
+                    raise ValidationError(
+                        {
+                            "custom_limits": "max_recipients_per_message must be a positive integer"
+                        }
+                    )
+
         super().clean()
♻️ Duplicate comments (2)
src/backend/core/tests/models/test_maildomain.py (1)

163-177: CAN_READ inconsistency for superuser should be addressed.

Line 170 asserts abilities["get"] is False for a superuser, but a superuser has patch, put, post, delete, manage_accesses, manage_mailboxes, and manage_settings all True. This creates an inconsistency where a superuser can modify or delete a domain but cannot read it.

Based on the get_abilities implementation in src/backend/core/models.py (lines 350-388), CAN_READ is set to bool(role) without checking superuser status. Consider updating the model to include superuser in the read check:

CAN_READ: bool(role) or user.is_superuser,

This would align read permissions with write permissions for superusers.

src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (1)

262-268: Add ability check for Settings action.

The Settings option should check CAN_MANAGE_SETTINGS ability before being shown, consistent with how the domain page checks CAN_MANAGE_MAILDOMAIN_SETTINGS (src/frontend/src/pages/domain/index.tsx lines 202, 215-224). Currently, the Settings action is always displayed when canManageMailboxes is true, regardless of the user's settings management permission.

To fix this, you should:

  1. Check the mailbox's CAN_MANAGE_SETTINGS ability in the parent component (where abilities are available)
  2. Conditionally pass the onManageSettings callback (similar to onResetPassword pattern on line 147)
  3. Only render the Settings option in the dropdown when the callback is provided

For example, in the parent component around line 150:

onManageSettings={mailbox.abilities[Abilities.CAN_MANAGE_SETTINGS] ? () => handleManageSettings(row) : undefined}

And in ActionsRow, make the props optional and filter out undefined callbacks in the options array.

🧹 Nitpick comments (11)
src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (3)

65-65: Avoid using hardcoded waitForTimeout.

waitForTimeout(50) is fragile and can cause flaky tests. Consider using a more reliable wait condition such as waiting for the input to be enabled or for a specific network request to complete.

-    await page.waitForTimeout(50);
+    // Wait for modal animation to complete by checking input is interactable

83-84: Same concern: avoid hardcoded waitForTimeout.

This pattern appears again. Consider extracting a helper function if you need to wait for modal transitions, or use Playwright's auto-waiting by interacting with elements directly.


89-100: Complete or remove the placeholder test.

The test "should domain admin can customize mailbox max recipients per message" is incomplete with a TODO comment. Incomplete tests can mask missing coverage in CI reports. Either:

  1. Complete the implementation
  2. Mark it as test.skip with a tracking issue
  3. Remove it until ready

Would you like me to help implement this test or create an issue to track it?

src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (1)

116-127: Consider adding aria-describedby for accessibility.

The Input component has helper text, but ensure it's properly associated with the input for screen readers. If the Input component from @openfun/cunningham-react doesn't handle this automatically, consider adding explicit aria-describedby.

src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (3)

24-29: Consider using a generated hook instead of manual state and fetchAPI.

The mailbox settings modal (modal-mailbox-manage-settings) uses useMaildomainsMailboxesSettingsUpdate hook which manages pending state automatically. This component uses manual isSubmitting state and direct fetchAPI calls.

For consistency and to leverage automatic cache invalidation, consider using or generating a hook for domain settings updates similar to the mailbox pattern.


80-86: Fragile query invalidation using string matching.

The predicate-based invalidation using string matching on query keys (key.includes('/api/v1.0/maildomains')) is fragile and could break if query key formats change. Consider using the query key factories from the generated API code for more robust invalidation.

// Instead of string matching, use the generated query key patterns
import { getMaildomainsListQueryKey } from '@/features/api/gen';

await queryClient.invalidateQueries({
  queryKey: getMaildomainsListQueryKey(), // or similar
});

48-64: Consider extracting shared validation logic.

The validation logic for max_recipients_per_message (positive integer or empty, not exceeding global max) is duplicated between this component and modal-mailbox-manage-settings. Consider extracting this into a shared utility function.

// utils/validation.ts
export const validateMaxRecipients = (
  value: string,
  maxGlobal: number,
  t: TFunction
): string | null => {
  const trimmed = value.trim();
  if (trimmed === '') return null;
  
  const num = Number(trimmed);
  if (isNaN(num) || num < 1 || !Number.isInteger(num)) {
    return t('Please enter a valid positive integer or leave empty.');
  }
  if (num > maxGlobal) {
    return t('The limit cannot exceed the global maximum of {{max}} recipients.', { max: maxGlobal });
  }
  return null;
};
src/backend/core/models.py (1)

530-533: Consider removing redundant save() method.

BaseModel.save() (lines 92-95) already calls full_clean() before saving, so this override is redundant. Since Mailbox extends BaseModel, the parent implementation already provides this behavior.

Apply this diff to remove the redundant method:

-    def save(self, *args, **kwargs):
-        """Enforce validation before saving."""
-        self.full_clean()
-        super().save(*args, **kwargs)
-
     def clean(self):
src/backend/core/api/openapi.json (2)

3455-3460: Optional: mark nested mailbox id parameters as UUIDs for better typing

These id path parameters currently expose only "type": "string", whereas other mailbox/domain identifiers in the spec typically also declare "format": "uuid". Consider adding the format for consistency and stronger generated client types.

Also applies to: 3497-3503, 3553-3560, 3592-3598


5787-5813: Consider typing custom_limits consistently in update request/response schemas

In MailDomainAdminUpdate*, MailboxAdminUpdateMetadataRequest, MailboxSettingsUpdate, and their patched request variants, custom_limits is currently untyped (no type/properties), while the read models (MailDomainAdmin.custom_limits, MailboxAdmin.custom_limits) define a concrete object with max_recipients_per_message. For stronger client codegen and validation, consider reusing a shared CustomLimits schema (or mirroring the object definition) here as well.

Also applies to: 6356-6367, 6403-6420, 7122-7130, 7153-7161

src/frontend/src/pages/domain/index.tsx (1)

2-2: Remove unused import.

The useModal import is not used anywhere in the file. Please remove it to keep the code clean.

Apply this diff:

-import { Button, DataGrid, usePagination, useModal } from "@openfun/cunningham-react";
+import { Button, DataGrid, usePagination } from "@openfun/cunningham-react";
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0014c47 and 2169aab.

⛔ Files ignored due to path filters (19)
  • src/frontend/src/features/api/gen/maildomains/maildomains.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/config_retrieve200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_custom_limits.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mail_domain_admin_write_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_abilities.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_create.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_custom_limits.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_admin_update_metadata_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_settings_update.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mail_domain_admin_update_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/patched_mailbox_settings_update_request.ts is excluded by !**/gen/**
📒 Files selected for processing (31)
  • env.d/development/backend.e2e (1 hunks)
  • src/backend/core/api/openapi.json (22 hunks)
  • src/backend/core/api/permissions.py (1 hunks)
  • src/backend/core/api/serializers.py (12 hunks)
  • src/backend/core/api/viewsets/config.py (4 hunks)
  • src/backend/core/api/viewsets/mailbox.py (3 hunks)
  • src/backend/core/api/viewsets/maildomain.py (6 hunks)
  • src/backend/core/enums.py (2 hunks)
  • src/backend/core/mda/draft.py (3 hunks)
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py (1 hunks)
  • src/backend/core/models.py (9 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_list.py (3 hunks)
  • src/backend/core/tests/api/test_admin_maildomains_update.py (1 hunks)
  • src/backend/core/tests/api/test_config.py (2 hunks)
  • src/backend/core/tests/api/test_mailboxes.py (4 hunks)
  • src/backend/core/tests/api/test_messages_create.py (2 hunks)
  • src/backend/core/tests/models/test_mailbox.py (6 hunks)
  • src/backend/core/tests/models/test_maildomain.py (2 hunks)
  • src/backend/messages/settings.py (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (1 hunks)
  • src/e2e/src/__tests__/message-max-recipients.spec.ts (1 hunks)
  • src/frontend/src/features/forms/components/message-form/index.tsx (10 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx (2 hunks)
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (7 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (1 hunks)
  • src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1 hunks)
  • src/frontend/src/features/providers/config.tsx (1 hunks)
  • src/frontend/src/hooks/use-ability.ts (3 hunks)
  • src/frontend/src/pages/domain/index.tsx (6 hunks)
✅ Files skipped from review due to trivial changes (1)
  • env.d/development/backend.e2e
🚧 Files skipped from review as they are similar to previous changes (15)
  • src/backend/core/api/permissions.py
  • src/frontend/src/features/layouts/components/admin/domains-view/settings-domain-action.tsx
  • src/backend/core/tests/api/test_admin_maildomains_update.py
  • src/backend/core/tests/api/test_config.py
  • src/frontend/src/features/forms/components/react-hook-form/rhf-json-schema-field.tsx
  • src/backend/core/tests/models/test_mailbox.py
  • src/e2e/src/tests/message-max-recipients.spec.ts
  • src/frontend/src/features/forms/components/react-hook-form/rhf-contact-combobox.tsx
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/enums.py
  • src/backend/messages/settings.py
  • src/frontend/src/features/providers/config.tsx
  • src/backend/core/tests/api/test_admin_maildomains_list.py
  • src/backend/core/migrations/0012_mailbox_custom_attributes_mailbox_custom_limits.py
  • src/backend/core/api/viewsets/config.py
🧰 Additional context used
📓 Path-based instructions (5)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/mda/draft.py
  • src/backend/core/models.py
  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/api/viewsets/maildomain.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/mailbox.py
  • src/backend/core/tests/models/test_maildomain.py
src/backend/**/{models.py,forms.py,views.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Keep business logic in models and forms; keep views thin and focused on request handling

Files:

  • src/backend/core/models.py
src/backend/**/{models.py,migrations/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Implement database indexing and query optimization (Model Meta indexes, constraints)

Files:

  • src/backend/core/models.py
src/backend/**/{tests.py,tests/**/*.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/{tests.py,tests/**/*.py}: Use Django’s testing tools (pytest-django) to ensure code quality and reliability
Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases

Files:

  • src/backend/core/tests/api/test_messages_create.py
  • src/backend/core/tests/models/test_maildomain.py
src/backend/{templates/**/*.html,**/serializers.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Use Django templates for HTML and DRF serializers for JSON responses

Files:

  • src/backend/core/api/serializers.py
🧠 Learnings (4)
📚 Learning: 2025-09-02T12:20:22.006Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/features/layouts/components/admin/modal-create-domain/index.tsx:32-37
Timestamp: 2025-09-02T12:20:22.006Z
Learning: In the messages application, `SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN` from the config defaults to an empty object `{}` rather than being potentially undefined, so null checks are not needed when using this configuration value.

Applied to files:

  • src/frontend/src/features/forms/components/message-form/index.tsx
  • src/backend/core/api/openapi.json
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Optimize related object fetching with select_related and prefetch_related

Applied to files:

  • src/backend/core/models.py
📚 Learning: 2025-09-02T12:42:29.083Z
Learnt from: Nastaliss
Repo: suitenumerique/messages PR: 313
File: src/frontend/src/pages/domain/index.tsx:0-0
Timestamp: 2025-09-02T12:42:29.083Z
Learning: The domain admin pages in src/frontend/src/pages/domain/ use AdminLayout, not the main layout. Toast notifications for admin pages should be handled via a Toaster instance in the AdminLayout component.

Applied to files:

  • src/frontend/src/pages/domain/index.tsx
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Use Django REST Framework viewsets for API endpoints

Applied to files:

  • src/backend/core/api/viewsets/maildomain.py
  • src/backend/core/api/viewsets/mailbox.py
🧬 Code graph analysis (11)
src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (2)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (15-43)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (1)
  • ModalMailboxManageSettings (24-143)
src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (5)
src/frontend/src/features/api/gen/models/mailbox_admin.ts (1)
  • MailboxAdmin (15-43)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • useMaildomainsMailboxesSettingsUpdate (1869-1901)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (5)
src/frontend/src/features/api/gen/models/mail_domain_admin.ts (1)
  • MailDomainAdmin (14-30)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/api/fetch-api.ts (1)
  • fetchAPI (12-40)
src/frontend/src/features/ui/components/toaster/index.tsx (2)
  • addToast (63-76)
  • ToasterItem (16-61)
src/frontend/src/features/ui/components/banner/index.tsx (1)
  • Banner (17-45)
src/frontend/src/features/forms/components/message-form/index.tsx (2)
src/frontend/src/features/providers/config.tsx (1)
  • useConfig (69-75)
src/frontend/src/features/providers/mailbox.tsx (1)
  • useMailboxContext (355-361)
src/backend/core/models.py (4)
src/backend/core/api/fields.py (1)
  • JSONField (8-25)
src/backend/core/enums.py (3)
  • MailDomainAbilities (96-101)
  • MailDomainAccessRoleChoices (48-51)
  • MailboxAbilities (104-116)
src/backend/core/api/serializers.py (1)
  • get_max_recipients_per_message (293-295)
src/backend/core/api/viewsets/placeholder.py (1)
  • get (45-66)
src/backend/core/tests/api/test_messages_create.py (2)
src/backend/core/factories.py (3)
  • MailboxAccessFactory (99-109)
  • MailboxFactory (60-96)
  • MailDomainFactory (51-57)
src/backend/core/enums.py (1)
  • MailboxRoleChoices (15-21)
src/e2e/src/__tests__/message-max-recipients-admin.spec.ts (1)
src/e2e/src/utils-test.ts (1)
  • signInKeycloakIfNeeded (6-39)
src/frontend/src/pages/domain/index.tsx (4)
src/frontend/src/hooks/use-ability.ts (1)
  • Abilities (34-39)
src/frontend/src/features/api/gen/maildomains/maildomains.ts (1)
  • getMaildomainsListQueryOptions (102-131)
src/frontend/src/features/api/gen/models/mail_domain_admin.ts (1)
  • MailDomainAdmin (14-30)
src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1)
  • ModalUpdateDomain (24-153)
src/backend/core/api/serializers.py (1)
src/backend/core/models.py (18)
  • Meta (89-90)
  • Meta (199-202)
  • Meta (291-294)
  • Meta (465-477)
  • Meta (520-525)
  • Meta (790-794)
  • Meta (833-836)
  • Meta (1016-1021)
  • Meta (1188-1192)
  • Meta (1209-1213)
  • Meta (1250-1254)
  • Meta (1325-1329)
  • Meta (1556-1568)
  • Meta (1624-1628)
  • Meta (1664-1668)
  • Meta (1718-1722)
  • Mailbox (483-768)
  • MailDomain (243-426)
src/backend/core/api/viewsets/mailbox.py (4)
src/backend/core/models.py (1)
  • MailDomainAccess (1649-1671)
src/backend/core/tests/models/test_mailbox.py (1)
  • user (17-19)
src/backend/core/tests/models/test_maildomain.py (2)
  • user (16-18)
  • maildomain (22-24)
src/backend/core/enums.py (1)
  • MailDomainAccessRoleChoices (48-51)
src/backend/core/tests/models/test_maildomain.py (2)
src/backend/core/models.py (3)
  • get_abilities (225-240)
  • get_abilities (351-389)
  • get_abilities (619-681)
src/backend/core/api/serializers.py (4)
  • get_abilities (132-141)
  • get_abilities (165-167)
  • get_abilities (288-290)
  • get_abilities (932-934)
🔇 Additional comments (47)
src/backend/core/tests/api/test_messages_create.py (6)

810-836: LGTM! Good test coverage for basic recipient limit enforcement.

The test correctly verifies that a draft with recipients exceeding MAX_RECIPIENTS_PER_MESSAGE is rejected with a 400 response.


838-883: LGTM! Proper testing of mailbox-level override.

The test correctly verifies that custom_limits.max_recipients_per_message on a mailbox takes precedence over the global setting. Both the rejection (3 recipients > limit of 2) and success (2 recipients = limit) cases are covered.


885-934: LGTM! Proper testing of domain-level override.

The test correctly verifies that custom_limits.max_recipients_per_message on a mail domain takes precedence over the global setting when no mailbox-level override exists.


936-986: LGTM! Proper testing of precedence hierarchy.

The test correctly verifies that mailbox custom_limits takes priority over domain custom_limits, ensuring the full precedence chain (mailbox > domain > global) works as expected.


988-1031: LGTM! Draft update limit enforcement is properly tested.

The test verifies that recipient limits are enforced not only on draft creation but also on draft updates, preventing circumvention of limits via incremental updates.


1033-1078: LGTM! Test naming and comments are now accurate.

The test correctly verifies that MAX_DEFAULT_RECIPIENTS_PER_MESSAGE is enforced when no custom limits exist at the mailbox or domain level.

src/backend/core/tests/models/test_maildomain.py (2)

141-141: LGTM! Correctly asserts no manage_settings for users without access.

The assertion properly verifies that users without any role cannot manage settings.


160-161: LGTM! Correctly restricts manage_settings to superusers only.

The comment and assertion properly document that even domain admins cannot manage settings—only superusers can.

src/backend/core/mda/draft.py (3)

101-112: LGTM! Clean recipient limit enforcement in draft creation.

The implementation:

  1. Normalizes None values to empty lists early
  2. Calculates total recipients across all fields (to + cc + bcc)
  3. Raises a clear validation error when the limit is exceeded

The error message is descriptive and includes both the actual count and the maximum allowed.


184-186: LGTM! Simplified recipient storage.

Direct assignment of recipient lists without fallback logic is cleaner since normalization happens earlier at lines 102-104.


252-274: LGTM! Correct handling of partial updates for recipient limits.

The implementation properly:

  1. Counts current recipients per type from the database
  2. Computes the total after update by using new values when provided or existing counts when not
  3. Validates against the same max_recipients limit used in creation

This prevents circumventing limits by incrementally adding recipients across multiple update calls.

src/frontend/src/features/forms/components/message-form/index.tsx (5)

57-101: LGTM! Well-designed dynamic schema with recipient limit validation.

The schema factory pattern is appropriate here since the max recipients limit can vary per mailbox. The superRefine implementation:

  1. Only validates when maxRecipients is defined
  2. Correctly sums all recipient fields
  3. Adds errors only to fields that have values (avoiding confusing errors on hidden cc/bcc fields)

228-240: LGTM! Proper state management for dynamic validation.

The implementation correctly:

  1. Initializes the limit from the selected mailbox or falls back to config defaults
  2. Memoizes the schema to regenerate only when the limit changes
  3. Memoizes the resolver to update when the schema changes (addressing the previous review feedback)

257-279: LGTM! Reactive limit updates with proper revalidation.

The two effects work together correctly:

  1. First effect updates maxRecipientsLimit when sender mailbox changes
  2. Second effect triggers revalidation only when there are recipients to validate

The conditional revalidation prevents unnecessary validation runs on initial render.


268-268: Potential stale closure issue with maxRecipientsLimit in dependency array.

The effect at lines 257-268 includes maxRecipientsLimit in its dependency array, which means it re-runs when the limit changes. However, since this effect is responsible for setting maxRecipientsLimit, including it as a dependency is technically correct but could be confusing. The current implementation works because of the newLimit !== maxRecipientsLimit guard.


479-479: keepErrors: true may preserve stale validation errors.

When form.reset is called with keepErrors: true, validation errors from before the reset will persist even if the values haven't changed. This could lead to stale error messages being displayed after a successful auto-save if validation was triggered between saves.

Consider whether this is the intended behavior. If errors should be cleared on successful save, remove keepErrors: true.

src/frontend/src/features/layouts/components/admin/modal-mailbox-manage-settings/index.tsx (2)

1-11: LGTM! Clean imports and component setup.

The component properly imports all necessary dependencies and uses the generated API hook for the settings update.


48-93: LGTM! Proper validation and error handling.

The submit handler:

  1. Validates input as positive integer or empty
  2. Validates against global maximum
  3. Uses the dedicated /settings/ endpoint via the generated hook
  4. Handles success with toast notification and modal close
  5. Handles errors by displaying them in a banner

Since isPending is managed by the useMaildomainsMailboxesSettingsUpdate hook (line 31), there's no manual state to reset on error—the hook handles this automatically.

src/frontend/src/features/layouts/components/admin/modal-maildomain-manage-settings/index.tsx (1)

102-104: LGTM! Proper state reset in finally block.

The finally block ensures isSubmitting is reset regardless of success or failure, preventing the Save button from being permanently disabled after an error.

src/backend/core/models.py (7)

282-289: LGTM!

The custom_limits field definition follows the same pattern as custom_attributes and includes appropriate help text.


387-388: LGTM!

The restriction to superusers for managing mail domain settings is clearly documented and appropriately implemented.


511-518: LGTM!

The custom_limits field definition is consistent with the pattern established in MailDomain.


535-552: LGTM!

The custom_limits validation is thorough and handles both structure and value constraints correctly.


623-656: LGTM!

The role initialization to 0 provides safer default behavior for integer comparisons. The is_domain_admin check follows the same annotation-first pattern as user_role, which addresses the N+1 query concern when the viewset properly annotates the queryset.


671-671: LGTM!

The CAN_MANAGE_SETTINGS ability correctly uses is_domain_admin, consistent with the access control model.


739-768: LGTM!

The priority cascade (mailbox → domain → global) is correctly implemented, with appropriate type checking and enforcement of the global maximum at each level.

src/backend/core/api/openapi.json (8)

221-235: Config schema: recipient limit constants are well introduced

The two new integer properties (MAX_RECIPIENTS_PER_MESSAGE, MAX_DEFAULT_RECIPIENTS_PER_MESSAGE) with clear descriptions and inclusion in required look consistent with the existing size limits block and appropriate for frontend consumption.

Also applies to: 237-251


3042-3142: Maildomain PUT/PATCH endpoints for custom_limits look coherent

Adding full and partial update operations on /maildomains/{maildomain_pk}/ using the dedicated MailDomainAdminUpdate* schemas is consistent with the admin surface and keeps custom_limits updates clearly scoped.


3662-3725: New /mailboxes/{id}/settings/ endpoint is wired cleanly to settings schemas

The mailbox settings PATCH endpoint under a maildomain, using PatchedMailboxSettingsUpdateRequest and returning MailboxSettingsUpdate, matches the intended “custom_limits-only” semantics and reuses existing auth/tag patterns; the documented 400/403 responses are sensible.


5714-5785: MailDomainAdmin: custom_limits shape and manage_settings ability are consistent

Exposing custom_limits as a nullable object with a typed max_recipients_per_message field, plus adding a manage_settings ability (and marking both as required) aligns well with the new settings feature and gives clients a clear, strongly-typed read model.


5815-5817: Maildomain create admin descriptions are clearer now

The updated descriptions on MailDomainAdminWrite and MailDomainAdminWriteRequest better communicate their “create admin view” role without altering the schema surface; no issues spotted.

Also applies to: 5863-5865


5918-5921: Mailbox schema: max_recipients_per_message and manage_settings ability look correct

max_recipients_per_message is now an integer, readOnly field and included in required, and the manage_settings ability is added and required alongside existing abilities; this matches the numeric semantics of the limits and the new settings permission model.

Also applies to: 5970-5988, 5993-5999


6206-6226: MailboxAdmin.read: custom_limits exposure is aligned with domain-level shape

The new nullable custom_limits object with max_recipients_per_message in MailboxAdmin, plus adding it to required, is consistent with MailDomainAdmin and gives admin UIs a predictable place to read mailbox-level limits.


6234-6236: MailboxAdminCreate doc correctly defers custom_limits to /settings/

The note clarifying that custom_limits cannot be set at creation time and must be managed via the /settings/ endpoint matches the new API design and should help avoid misuse by clients.

src/backend/core/api/viewsets/mailbox.py (1)

3-3: LGTM! Efficient domain admin status annotation.

The is_domain_admin annotation correctly uses Exists to check domain admin status in a single query, avoiding N+1 query issues. This aligns well with the new settings management permissions introduced across the PR.

Also applies to: 27-27, 36-44

src/frontend/src/hooks/use-ability.ts (1)

15-15: LGTM! Abilities correctly added for settings management.

The new CAN_MANAGE_SETTINGS and CAN_MANAGE_MAILDOMAIN_SETTINGS abilities are properly integrated into the ability checking system, following the existing patterns consistently.

Also applies to: 27-27, 84-86

src/frontend/src/pages/domain/index.tsx (2)

104-123: LGTM! Fresh domain lookup ensures modal has latest data.

The pattern of fetching freshDomain from the current domains list (line 106) ensures the modal always receives the most up-to-date domain data. The key using updated_at (line 109) correctly forces remounting when the domain is updated.


199-227: LGTM! ActionsCell properly gates actions by abilities.

The component correctly checks CAN_MANAGE_MAILDOMAIN_ACCESSES and CAN_MANAGE_MAILDOMAIN_SETTINGS abilities before rendering their respective buttons. The early return when neither ability is present (line 204) is good practice.

src/frontend/src/features/layouts/components/admin/mailboxes-view/mailbox-data-grid.tsx (1)

60-63: LGTM! Modal integration follows established patterns.

The settings modal integration correctly follows the same pattern as other modal actions in this component, with proper key management for remounting and appropriate refetch behavior.

Also applies to: 218-225

src/backend/core/api/viewsets/maildomain.py (3)

7-7: LGTM! Update support correctly added with proper permissions.

The addition of UpdateModelMixin and the use of CanManageSettings permission for update actions properly enables domain settings updates with appropriate authorization checks.

Also applies to: 41-41, 59-68


167-203: LGTM! Queryset annotations and permissions properly configured.

The annotations for user_role and is_domain_admin follow the same efficient pattern used in src/backend/core/api/viewsets/mailbox.py, and the permission/serializer selection for update_settings is correctly implemented.


375-405: LGTM! Settings update action properly implemented.

The update_settings action is well-structured with clear OpenAPI documentation, proper permission enforcement through get_permissions, and a clean implementation that delegates validation to the serializer.

src/backend/core/api/serializers.py (5)

7-7: LGTM! max_recipients_per_message field correctly exposed.

The read-only field properly delegates to the model's get_max_recipients_per_message() method, following Django REST Framework best practices for computed fields.

Also applies to: 220-220, 224-231, 292-295


998-1017: LGTM! Validation correctly enforces limits.

The CustomLimitsUpdateMixin properly validates max_recipients_per_message:

  • Allows None (to use inherited limits)
  • Enforces positive values
  • Enforces global maximum from settings
  • Provides clear, translatable error messages

1020-1043: LGTM! Update serializers are focused and well-structured.

Both MailDomainAdminUpdateSerializer and MailboxSettingsUpdateSerializer correctly inherit the validation mixin and expose only the necessary fields for settings updates, following the single-responsibility principle.


874-904: LGTM! custom_limits properly exposed as read-only in admin serializers.

Both MailDomainAdminSerializer and MailboxAdminSerializer correctly expose custom_limits as read-only fields with detailed OpenAPI schemas. The comment on line 1120 appropriately directs users to the /settings/ endpoint for updates.

Also applies to: 908-915, 1074-1094, 1109-1120


1267-1288: LGTM! Creation serializer correctly excludes custom_limits.

The design decision to exclude custom_limits from the creation response and require the dedicated /settings/ endpoint for configuration is well-documented and implemented cleanly.

expect(finalValue).toBe(initialValue);
});

test("should domain admin can customize mailbox max recipients per message ", async ({ page, browserName }) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Grammar issue in test name.

The test name has a grammatical error: "should domain admin can" → "should allow domain admin to".

-  test("should domain admin can customize mailbox max recipients per message ", async ({ page, browserName }) => {
+  test("should allow domain admin to customize mailbox max recipients per message", async ({ page, browserName }) => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("should domain admin can customize mailbox max recipients per message ", async ({ page, browserName }) => {
test("should allow domain admin to customize mailbox max recipients per message", async ({ page, browserName }) => {
🤖 Prompt for AI Agents
In src/e2e/src/__tests__/message-max-recipients-admin.spec.ts around line 89,
the test title string contains a grammatical error: replace "should domain admin
can customize mailbox max recipients per message" with "should allow domain
admin to customize mailbox max recipients per message" by updating the test(...)
description accordingly to use correct grammar and infinitive form.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
src/backend/core/api/viewsets/maildomain.py (2)

173-180: Consider simplifying the permission composition.

The current implementation correctly enforces that users must satisfy (IsSuperUser | IsMailDomainAdmin) AND CanManageSettings, but the list comprehension with + could be more explicit.

🔎 Consider this alternative for clarity:
 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()
-        ]
+        # Domain admin + ability to manage settings
+        return [
+            core_permissions.IsSuperUser() | core_permissions.IsMailDomainAdmin(),
+            core_permissions.CanManageSettings(),
+        ]
     return super().get_permissions()

372-402: Consider adding logging for settings updates.

The update_settings action successfully implements the settings management endpoint, but it lacks logging. According to the coding guidelines, expected actions should be logged with appropriate levels.

Additionally, for consistency with other mutation endpoints in this viewset (create and partial_update), consider adding @transaction.atomic.

🔎 Consider these improvements:
+@transaction.atomic
 @action(detail=True, methods=["patch"], url_path="settings")
 def update_settings(self, request, maildomain_pk=None, pk=None):
     """
     Update mailbox settings (custom_limits).
 
     Only domain administrators can manage mailbox settings.
     This endpoint is separate from the general mailbox update endpoint
     to enforce proper permission checks at the viewset level.
     """
     mailbox = self.get_object()
     serializer = self.get_serializer(mailbox, data=request.data, partial=True)
     serializer.is_valid(raise_exception=True)
     serializer.save()
+    logger.info(
+        "Mailbox settings updated for mailbox %s by user %s",
+        mailbox.id,
+        request.user.id,
+    )
     return Response(serializer.data, status=status.HTTP_200_OK)

Based on coding guidelines, ...

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2169aab and 4f66ec2.

📒 Files selected for processing (1)
  • src/backend/core/api/viewsets/maildomain.py (6 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/api/viewsets/maildomain.py
🧠 Learnings (1)
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Use Django REST Framework viewsets for API endpoints

Applied to files:

  • src/backend/core/api/viewsets/maildomain.py
🔇 Additional comments (5)
src/backend/core/api/viewsets/maildomain.py (5)

7-7: LGTM!

The new imports are properly utilized in the queryset annotations below (lines 187-199).


41-41: LGTM!

The addition of UpdateModelMixin combined with appropriate permission and serializer handling for update actions follows Django REST Framework best practices and properly enables settings management on mail domains.

Also applies to: 59-68


277-277: LGTM!

Adding custom_limits to the OpenAPI schema properly documents the new field availability in the API contract.


167-171: LGTM!

The serializer selection logic properly returns the specialized serializer for the update_settings action.


184-200: No action needed — the annotations are properly utilized.

The user_role and is_domain_admin annotations are actively used as claimed:

  • Mailbox.get_abilities() directly accesses self.user_role (line 640) and self.is_domain_admin (line 629) to compute user abilities
  • The serializers reference user_role via get_user_role() method and in field lists
  • Both annotations have corresponding test coverage validating their functionality
  • The code includes appropriate try/except blocks checking for annotated attributes, showing intentional optimization for the N+1 query problem

The annotations are necessary optimizations that reduce database queries within get_abilities() computation.

Likely an incorrect or invalid review comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants