Skip to content

✨(quotas) add usage+quota gauge in front + empty trash button - #764

Open
sylvinus wants to merge 1 commit into
mainfrom
quotas
Open

✨(quotas) add usage+quota gauge in front + empty trash button#764
sylvinus wants to merge 1 commit into
mainfrom
quotas

Conversation

@sylvinus

@sylvinus sylvinus commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Added mailbox storage usage, quota, entitlement, and largest-conversation views.
    • Added permanent deletion actions for selected messages and entire Trash or Spam folders.
    • Added role-based permissions and confirmation messaging for irreversible deletions.
    • Added automatic cleanup of items exceeding the configured retention period.
  • Bug Fixes
    • Improved Trash and Spam timestamps to preserve retention periods when flags change.
    • Corrected storage calculations and cache invalidation after deletions.
  • Documentation
    • Expanded documentation for trash retention, manual deletion permissions, and storage caching.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds configurable trashbin retention and permanent deletion, mailbox storage usage and entitlement APIs, scheduled cleanup, storage caching, and frontend quota, storage-settings, retention-notice, and empty-trash controls.

Changes

Trashbin and mailbox storage

Layer / File(s) Summary
Contracts and policy wiring
docs/env.md, src/backend/core/api/openapi.json, src/backend/core/api/serializers.py, src/backend/core/enums.py, src/backend/core/models.py, src/backend/messages/settings.py
Adds trashbin policy settings, mailbox abilities, API schemas, serializers, and storage-related configuration contracts.
Storage computation and entitlements
src/backend/core/services/storage.py, src/backend/core/entitlements/..., src/backend/core/api/viewsets/metrics.py, src/backend/core/tests/entitlements/*, src/backend/core/tests/services/test_storage_cache.py
Adds shared mailbox and organization storage calculations, TTL caching and invalidation, entitlement backends, and metrics reuse.
Trashbin lifecycle and deletion
src/backend/core/services/trashbin.py, src/backend/core/api/viewsets/flag.py, src/backend/core/mda/inbound_create.py, src/backend/core/tasks.py, src/backend/messages/celery_app.py, src/backend/core/tests/tasks/*, src/backend/core/tests/importer/*, src/backend/core/tests/api/test_messages_flag.py
Preserves bin-entry timestamps, permanently deletes scoped messages, reconciles threads, schedules daily cutoff cleanup, and validates lifecycle behavior.
Mailbox storage and empty-trash API
src/backend/core/api/viewsets/mailbox.py, src/backend/core/tests/api/test_mailbox_empty_trash.py, src/backend/core/tests/api/test_mailbox_storage.py
Adds entitlement, storage-statistics, and scoped empty-trash actions with access, role, targeting, and aggregation coverage.
Frontend quota and trashbin controls
src/frontend/src/features/quota/*, src/frontend/src/features/layouts/components/mailbox-settings/*, src/frontend/src/features/message/use-empty-trash.tsx, src/frontend/src/features/layouts/components/thread-panel/*, src/frontend/public/locales/common/*
Adds quota gauges, mailbox storage settings, permanent-deletion confirmation flows, trashbin retention notices, and localized messages.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ThreadPanel
  participant EmptyTrashHook
  participant MailboxAPI
  participant TrashbinService
  User->>ThreadPanel: Selects Empty trash or Empty spam
  ThreadPanel->>EmptyTrashHook: Request confirmation
  EmptyTrashHook->>MailboxAPI: POST scoped deletion request
  MailboxAPI->>TrashbinService: Delete authorized messages
  TrashbinService-->>MailboxAPI: Return deleted_count
  MailboxAPI-->>EmptyTrashHook: Return deletion result
  EmptyTrashHook-->>ThreadPanel: Invalidate stats and show toast
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main user-facing changes: adding storage/quota gauges and an empty-trash action in the frontend.
Docstring Coverage ✅ Passed Docstring coverage is 82.20% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

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/mda/inbound_create.py (1)

519-541: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unguarded update_fields construction from message_flags.keys().

update_fields is built from all remaining message_flags keys without the same hasattr(message, flag) guard used in the setattr loop just above. If compute_labels_and_flags ever returns a key that isn't a genuine Message field, message.save(update_fields=update_fields) will raise, since Django validates every update_fields entry against the model's concrete fields.

Today this is probably safe (only is_trashed/is_spam-style keys survive after popping is_unread/_starred), but the asymmetry between the guarded setattr and the unguarded update_fields list is a latent trap for future flag additions.

#!/bin/bash
# Inspect compute_labels_and_flags to confirm every returned key (besides
# is_unread/_starred) maps to a real Message model field.
rg -n -A 40 'def compute_labels_and_flags' --type=py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/mda/inbound_create.py` around lines 519 - 541, Build
update_fields in the import flow using only message_flags keys that pass the
existing hasattr(message, flag) check in the setattr loop. Keep assigning
recognized flags as before, preserve created_at and trashed_at updates, and
exclude unknown keys so message.save(update_fields=...) only receives valid
Message fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/api/openapi.json`:
- Line 4271: Update the mailbox storage calculation in the viewset method that
builds LargestThread results so each thread’s total_size includes per-message
overhead in addition to blob and draft-blob bytes, matching the OpenAPI
description. Apply the same corrected storage basis to the related lines
8588-8590 and preserve the existing totals and ordering behavior.

In `@src/backend/core/api/viewsets/mailbox.py`:
- Around line 156-208: Reduce the query cost in the storage action by reusing
the cached mailbox storage accessor, such as get_mailbox_storage_used, instead
of recomputing totals with compute_mailbox_storage_used, and cache the remaining
storage payload or aggregates with a short TTL. Preserve the existing response
values and top-100 thread behavior while ensuring repeated Storage-tab requests
do not rescan the mailbox’s full message/blob set.
- Around line 241-251: Update the `largest_threads` annotation in the mailbox
storage calculation to include per-message overhead using the existing
`msg_count` annotation and overhead value, matching `_storage_for_messages` and
the documented per-thread sizing. Keep the existing blob and draft size
components and ordering intact.

In `@src/backend/core/entitlements/backends/local.py`:
- Around line 48-66: Update get_mailbox_entitlements in
src/backend/core/entitlements/backends/local.py (lines 48-66) to check org_value
with an explicit is not None comparison, matching the existing convention in
src/backend/core/entitlements/backends/deploycenter.py (lines 220-248). No
change is needed in deploycenter.py.

In `@src/backend/core/models.py`:
- Around line 2203-2207: Update msg_trashbin_cutoff_idx to use an expression
index on Coalesce("trashed_at", "created_at") while preserving the existing
is_trashed/is_spam partial condition, and add the corresponding database
migration.

In `@src/backend/core/services/trashbin.py`:
- Around line 160-175: Update the batch sweep around permanently_delete_messages
so mailbox storage cache invalidation occurs for each successfully processed
batch, or is guaranteed via try/finally when the loop aborts. Preserve
touched_mailbox_ids for all completed deletions so a later failure cannot skip
invalidation of earlier batches.

In `@src/backend/messages/settings.py`:
- Around line 1082-1088: The TRASHBIN_ALLOW_EMPTY setting currently defers
invalid-value detection until runtime; validate it against TrashbinAllowEmpty
during startup configuration while retaining runtime validation if
override_settings requires it. Update src/backend/messages/settings.py lines
1082-1088 at TRASHBIN_ALLOW_EMPTY, and update docs/env.md line 413 to retain the
startup-rejection claim only after startup validation is implemented.

In `@src/frontend/public/locales/common/fr-FR.json`:
- Line 1043: Update the French translation for “You are about to permanently
delete every message in the spam folder. This cannot be undone.” to use “dossier
des indésirables” instead of “dossier indésirables,” preserving the rest of the
translation.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/storage-tab/index.tsx`:
- Around line 198-201: Update the status indicator span in the mailbox settings
storage item to expose its existing read/unread aria-label to assistive
technology by adding role="img" (or an equivalent visually hidden text child),
while preserving the current label selection.

In `@src/frontend/src/features/message/use-empty-trash.tsx`:
- Around line 81-83: Update the onSuccess handler in use-empty-trash.tsx to use
the generated response type from useMailboxesEmptyTrashCreate directly, removing
the manual `{ deleted_count?: number }` cast. Access deleted_count through
response.data while preserving the existing nullish fallback to 0.

---

Outside diff comments:
In `@src/backend/core/mda/inbound_create.py`:
- Around line 519-541: Build update_fields in the import flow using only
message_flags keys that pass the existing hasattr(message, flag) check in the
setattr loop. Keep assigning recognized flags as before, preserve created_at and
trashed_at updates, and exclude unknown keys so message.save(update_fields=...)
only receives valid Message fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 77ac0eb8-0c4f-4b96-826c-e15b5602fdcf

📥 Commits

Reviewing files that changed from the base of the PR and between cf0b70e and bd2be6a.

⛔ Files ignored due to path filters (14)
  • src/frontend/src/features/api/gen/mailboxes/mailboxes.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/largest_thread.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_empty_trash_request_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_empty_trash_request_scope_enum.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_empty_trash_response.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_entitlements.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_entitlements_organization.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/mailbox_storage_stats.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/storage_entitlement.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_bulk_delete_request_request.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_bulk_delete_request_scope_enum.ts is excluded by !**/gen/**
📒 Files selected for processing (47)
  • docs/env.md
  • src/backend/core/api/openapi.json
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/config.py
  • src/backend/core/api/viewsets/flag.py
  • src/backend/core/api/viewsets/mailbox.py
  • src/backend/core/api/viewsets/metrics.py
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/entitlements/__init__.py
  • src/backend/core/entitlements/backends/base.py
  • src/backend/core/entitlements/backends/deploycenter.py
  • src/backend/core/entitlements/backends/local.py
  • src/backend/core/enums.py
  • src/backend/core/mda/inbound_create.py
  • src/backend/core/migrations/0035_message_msg_trashbin_cutoff_idx.py
  • src/backend/core/models.py
  • src/backend/core/services/storage.py
  • src/backend/core/services/trashbin.py
  • src/backend/core/tasks.py
  • src/backend/core/tests/api/test_config.py
  • src/backend/core/tests/api/test_mailbox_empty_trash.py
  • src/backend/core/tests/api/test_mailbox_entitlements.py
  • src/backend/core/tests/api/test_mailbox_storage.py
  • src/backend/core/tests/api/test_messages_flag.py
  • src/backend/core/tests/entitlements/test_mailbox_backends.py
  • src/backend/core/tests/importer/test_import_channel.py
  • src/backend/core/tests/services/test_storage_cache.py
  • src/backend/core/tests/tasks/test_cleanup_trashbin.py
  • src/backend/messages/celery_app.py
  • src/backend/messages/settings.py
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/public/locales/common/nl-NL.json
  • src/frontend/src/features/config/resolve.ts
  • src/frontend/src/features/layouts/components/mailbox-panel/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/storage-tab/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/message/use-delete.tsx
  • src/frontend/src/features/message/use-empty-trash.tsx
  • src/frontend/src/features/quota/api/use-mailbox-entitlements.ts
  • src/frontend/src/features/quota/components/quota-widget.scss
  • src/frontend/src/features/quota/components/quota-widget.tsx
  • src/frontend/src/hooks/use-ability.ts

"/api/v1.0/mailboxes/{id}/storage/": {
"get": {
"operationId": "mailboxes_storage_retrieve",
"description": "Return total storage and the top-100 largest threads for the mailbox.\n\nThe total is computed with the shared storage service (the same formula\nthe metrics endpoints and the quota gauge use), so the \"Total storage\nused\" here always matches the sidebar gauge. Per-thread sizes and the\ntrash/spam subtotals cover message overhead plus MIME and draft blobs —\nattachments and templates are not thread-scoped.\n\nBacks the Storage settings tab; mailbox admins only.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align LargestThread.size with the advertised storage basis.

Line 4271 promises per-thread sizes include message overhead, but src/backend/core/api/viewsets/mailbox.py currently returns only blob and draft-blob bytes. Add the per-message overhead to total_size (and update Lines 8588-8590), or remove the promise; the current contract is contradictory.

Also applies to: 8588-8590

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/openapi.json` at line 4271, Update the mailbox storage
calculation in the viewset method that builds LargestThread results so each
thread’s total_size includes per-message overhead in addition to blob and
draft-blob bytes, matching the OpenAPI description. Apply the same corrected
storage basis to the related lines 8588-8590 and preserve the existing totals
and ordering behavior.

Comment on lines +156 to +208
@action(detail=True, methods=["get"], url_path="storage")
def storage(self, request, **kwargs):
"""Return total storage and the top-100 largest threads for the mailbox.

The total is computed with the shared storage service (the same formula
the metrics endpoints and the quota gauge use), so the "Total storage
used" here always matches the sidebar gauge. Per-thread sizes and the
trash/spam subtotals cover message overhead plus MIME and draft blobs —
attachments and templates are not thread-scoped.

Backs the Storage settings tab; mailbox admins only.
"""
mailbox = self.get_object()
overhead = settings.METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE

thread_ids = models.ThreadAccess.objects.filter(mailbox=mailbox).values_list(
"thread_id", flat=True
)

# Total mirrors the gauge exactly (message overhead + every blob the
# mailbox reaches), so the two never disagree.
total_storage = compute_mailbox_storage_used(mailbox)
message_count = models.Message.objects.filter(thread__id__in=thread_ids).count()
thread_count = models.Thread.objects.filter(accesses__mailbox=mailbox).count()

# Storage held by trashed / spam mail: message overhead plus MIME and
# draft blobs — the same basis as the per-thread sizes below
# (attachments/templates are not thread-scoped).
#
# Scoped per MESSAGE, not per thread. The thread-level flags are
# denormalizations that answer a different question: Thread.is_trashed
# is true only when *every* message is trashed, and Thread.is_spam
# mirrors the *first* message alone (see Thread.update_stats). Summing
# whole threads through them therefore both under-counted (a partly
# trashed thread contributed nothing) and over-counted (a spam-first
# thread contributed its non-spam messages too). Message-level scoping
# matches what ``empty_trashbin`` actually deletes.
#
# ``blob`` and ``draft_blob`` are both forward FKs, so joining them adds
# no row fan-out and Count() stays one-per-message. Blobs are summed per
# referencing message rather than deduplicated, which is the same
# "storage felt" basis as the total above (see core.services.storage).
def _storage_for_messages(condition):
return models.Message.objects.filter(
condition, thread_id__in=thread_ids
).aggregate(
total=Coalesce(Sum("blob__size_compressed"), Value(0))
+ Coalesce(Sum("draft_blob__size_compressed"), Value(0))
+ Count("id") * overhead
)["total"]

trashed_storage = _storage_for_messages(Q(is_trashed=True))
spam_storage = _storage_for_messages(Q(is_spam=True))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

storage runs several unbounded aggregate scans per request.

compute_mailbox_storage_used, two message-level aggregates, a message count, a thread count and the top-100 annotation each traverse the mailbox's whole message/blob set on every open of the Storage tab. Consider serving the totals from the cached storage accessors (get_mailbox_storage_used, as the entitlements backend does) and/or caching this payload with a short TTL, so a large mailbox cannot make the settings modal an expensive query burst.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/viewsets/mailbox.py` around lines 156 - 208, Reduce the
query cost in the storage action by reusing the cached mailbox storage accessor,
such as get_mailbox_storage_used, instead of recomputing totals with
compute_mailbox_storage_used, and cache the remaining storage payload or
aggregates with a short TTL. Preserve the existing response values and top-100
thread behavior while ensuring repeated Storage-tab requests do not rescan the
mailbox’s full message/blob set.

Comment on lines +241 to +251
largest_threads = (
models.Thread.objects.filter(accesses__mailbox=mailbox)
.annotate(
blob_size=Coalesce(thread_size_subquery, Value(0)),
draft_size=Coalesce(thread_draft_size_subquery, Value(0)),
msg_count=Coalesce(thread_msg_count_subquery, Value(0)),
access_read_at=read_at_subquery,
)
.annotate(total_size=F("blob_size") + F("draft_size"))
.order_by("-total_size")[:100]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Per-thread total_size omits the per-message overhead the docstring and the trash/spam subtotals include.

Lines 162-164 state per-thread sizes "cover message overhead plus MIME and draft blobs", and _storage_for_messages adds Count("id") * overhead, but total_size is only blob_size + draft_size. msg_count is annotated yet unused in the sum, so the "Largest conversations" list under-reports (and can mis-order) relative to the trash/spam figures and the total. Either include the overhead or correct the docstring.

🐛 Proposed fix
-            .annotate(total_size=F("blob_size") + F("draft_size"))
+            .annotate(
+                total_size=F("blob_size") + F("draft_size") + F("msg_count") * overhead
+            )
📝 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
largest_threads = (
models.Thread.objects.filter(accesses__mailbox=mailbox)
.annotate(
blob_size=Coalesce(thread_size_subquery, Value(0)),
draft_size=Coalesce(thread_draft_size_subquery, Value(0)),
msg_count=Coalesce(thread_msg_count_subquery, Value(0)),
access_read_at=read_at_subquery,
)
.annotate(total_size=F("blob_size") + F("draft_size"))
.order_by("-total_size")[:100]
)
largest_threads = (
models.Thread.objects.filter(accesses__mailbox=mailbox)
.annotate(
blob_size=Coalesce(thread_size_subquery, Value(0)),
draft_size=Coalesce(thread_draft_size_subquery, Value(0)),
msg_count=Coalesce(thread_msg_count_subquery, Value(0)),
access_read_at=read_at_subquery,
)
.annotate(
total_size=F("blob_size") + F("draft_size") + F("msg_count") * overhead
)
.order_by("-total_size")[:100]
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/api/viewsets/mailbox.py` around lines 241 - 251, Update the
`largest_threads` annotation in the mailbox storage calculation to include
per-message overhead using the existing `msg_count` annotation and overhead
value, matching `_storage_for_messages` and the documented per-thread sizing.
Keep the existing blob and draft size components and ordering intact.

Comment on lines +48 to +66
def get_mailbox_entitlements(self, mailbox, force_refresh=False):
account = {
"storage_used": get_mailbox_storage_used(mailbox),
"max_storage": self.mailbox_storage_limit,
}

organization = None
org_value = (mailbox.domain.custom_attributes or {}).get(
self.organization_claim
)
if org_value:
organization = {
"storage_used": get_organization_storage_used(
self.organization_claim, org_value
),
"max_storage": self.organization_storage_limit,
}

return {"account": account, "organization": organization}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align org_value truthiness check between the two entitlements backends. Both backends derive org_value the same way, but local.py uses a truthy check while deploycenter.py uses is not None; an empty-string organization claim would be handled differently depending on which backend is configured, breaking the interchangeability the EntitlementsBackend abstraction relies on.

  • src/backend/core/entitlements/backends/local.py#L48-L66: change if org_value: to if org_value is not None: to match deploycenter.py.
  • src/backend/core/entitlements/backends/deploycenter.py#L220-L248: no change needed; kept as the reference convention (already consistent with _build_usage_metrics's org_value is None check).
🐛 Proposed fix
--- a/src/backend/core/entitlements/backends/local.py
+++ b/src/backend/core/entitlements/backends/local.py
@@
-        if org_value:
+        if org_value is not None:
             organization = {
📝 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
def get_mailbox_entitlements(self, mailbox, force_refresh=False):
account = {
"storage_used": get_mailbox_storage_used(mailbox),
"max_storage": self.mailbox_storage_limit,
}
organization = None
org_value = (mailbox.domain.custom_attributes or {}).get(
self.organization_claim
)
if org_value:
organization = {
"storage_used": get_organization_storage_used(
self.organization_claim, org_value
),
"max_storage": self.organization_storage_limit,
}
return {"account": account, "organization": organization}
def get_mailbox_entitlements(self, mailbox, force_refresh=False):
account = {
"storage_used": get_mailbox_storage_used(mailbox),
"max_storage": self.mailbox_storage_limit,
}
organization = None
org_value = (mailbox.domain.custom_attributes or {}).get(
self.organization_claim
)
if org_value is not None:
organization = {
"storage_used": get_organization_storage_used(
self.organization_claim, org_value
),
"max_storage": self.organization_storage_limit,
}
return {"account": account, "organization": organization}
📍 Affects 2 files
  • src/backend/core/entitlements/backends/local.py#L48-L66 (this comment)
  • src/backend/core/entitlements/backends/deploycenter.py#L220-L248
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/entitlements/backends/local.py` around lines 48 - 66, Update
get_mailbox_entitlements in src/backend/core/entitlements/backends/local.py
(lines 48-66) to check org_value with an explicit is not None comparison,
matching the existing convention in
src/backend/core/entitlements/backends/deploycenter.py (lines 220-248). No
change is needed in deploycenter.py.

Comment on lines +2203 to +2207
models.Index(
fields=["trashed_at", "created_at"],
name="msg_trashbin_cutoff_idx",
condition=models.Q(is_trashed=True) | models.Q(is_spam=True),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate models.py =="
fd -a 'models.py$' . | sed 's#^\./##' | head -50

echo "== inspect relevant lines in src/backend/core/models.py =="
wc -l src/backend/core/models.py
sed -n '2170,2225p' src/backend/core/models.py

echo "== search cleanup_trashbin_task =="
rg -n "cleanup_trashbin_task|trashed_at|is_trashed|is_spam|Coalesce|coalesce" src/backend -S

echo "== migrations mentioning msg_trashbin_cutoff_idx or Message indexing =="
rg -n "msg_trashbin_cutoff_idx|trashbin_cutoff_idx|is_trashed|is_spam|Model indexes" src/backend src/backend/migrations -S || true

echo "== git diff summary/name-only =="
git diff --stat || true
git diff --name-only || true

Repository: suitenumerique/messages

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== trashbin task implementation =="
sed -n '1,190p' src/backend/core/services/trashbin.py

echo "== migration creating the index =="
sed -n '1,140p' src/backend/core/migrations/0035_message_msg_trashbin_cutoff_idx.py

echo "== model imports relevant to Coalesce for expression indexes =="
sed -n '1,80p' src/backend/core/models.py

echo "== behavioral planner proxy: compare SQL generated for current fields index vs expression index with Django if available locally, otherwise use read-only AST/text parser =="
python3 - <<'PY'
import ast, pathlib, re
models = pathlib.Path('src/backend/core/models.py').read_text(encoding='utf-8')
tree = ast.parse(models)
for cls in tree.body:
    if isinstance(cls, ast.ClassDef) and cls.name == 'Message':
        indexes = []
        for item in cls.body:
            if isinstance(item, ast.Assign) and any(getattr(a,'id','')=='indexes' for a in item.targets):
                for val in item.value.elements:
                    idx = {}
                    if isinstance(val, ast.Call) and getattr(val.func,'id','') == 'models.Index':
                        for kw in val.keywords:
                            if kw.arg == 'fields':
                                idx['fields'] = [elt.s if isinstance(elt, ast.Constant) else ast.unparse(elt) for elt in kw.value.elts]
                            elif kw.arg == 'name':
                                idx['name'] = kw.value.s
                            elif kw.arg == 'expression':
                                idx['expression'] = ast.unparse(kw.value)
                    indexes.append(idx)
        print(indexes)
PY

echo "== Django version if installed =="
python3 - <<'PY'
try:
    import django
    print(django.VERSION)
except Exception as exc:
    print('django_not_importable:', type(exc).__name__, str(exc))
PY

Repository: suitenumerique/messages

Length of output: 11070


🌐 Web query:

Django expression Index Coalesce function fields vs expression documentation PostgreSQL functional index

💡 Result:

In Django, you can create functional indexes using the Index class by passing expressions (including database functions like Coalesce) as positional arguments, rather than using the fields argument [1][2]. Since Django 3.2, native support for functional indexes exists, rendering external packages like django-expression-index obsolete [3][4]. Key technical distinctions: 1. Fields vs. Expressions: The Index class accepts either fields (a list of field names) or expressions (positional arguments) [1][2]. When using fields, Django creates a standard index on those columns [2]. When using expressions, you enable functional indexes, which index the result of a database function (e.g., Coalesce, Lower) applied to your model fields [1][5]. 2. Coalesce in Functional Indexes: You can use Coalesce within an Index by passing it as a positional argument [6]. A common use case is to provide a non-null default value for an index on nullable fields [6]. Example usage: Index(Coalesce("field_name", Value("default_value")), name="coalesce_idx") [6]. 3. PostgreSQL Considerations: PostgreSQL requires that functions used in functional indexes be marked as IMMUTABLE [7]. While Coalesce is generally supported, be aware that complex expressions or those involving non-immutable data may fail at the database level [7]. Additionally, using Coalesce in certain contexts (like full-text search) may inadvertently block the usage of indexes if the optimizer cannot determine that the result is equivalent to the underlying column or if nullability handling differs [8]. 4. Documentation: The official Django Model index reference documentation confirms that the *expressions positional argument is the correct way to implement indexes on database functions [1][5]. For the behavior of Coalesce itself, refer to the Database Functions documentation [9][10][11]. Note: When using complex expressions in indexes, ensure your migrations are stable. Some versions of Django have had issues with redundant migration generation when using Coalesce within indexes, particularly when the output_field type is implicitly or explicitly defined [12].

Citations:


Index the coalesced trashbin timestamp.

cleanup_trashbin_task filters Coalesce("trashed_at", "created_at") < cutoff, but msg_trashbin_cutoff_idx indexes the raw columns. A b-tree on trashed_at, created_at does not satisfy the coalesced range predicate, so the daily sweep may still scan broader index matching rows. Use an expression index for Coalesce("trashed_at", "created_at") with the same partial condition and add/update the migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/models.py` around lines 2203 - 2207, Update
msg_trashbin_cutoff_idx to use an expression index on Coalesce("trashed_at",
"created_at") while preserving the existing is_trashed/is_spam partial
condition, and add the corresponding database migration.

Source: Coding guidelines

Comment on lines +160 to +175
while True:
batch_ids = list(base_qs.values_list("pk", flat=True)[:batch_size])
if not batch_ids:
break
# Collected before the delete: the ThreadAccess rows are unreachable
# through the messages once they are gone.
touched_mailbox_ids.update(
models.ThreadAccess.objects.filter(
thread__messages__pk__in=batch_ids
).values_list("mailbox_id", flat=True)
)
deleted_count += permanently_delete_messages(
models.Message.objects.filter(pk__in=batch_ids)
)

invalidate_mailbox_storage_ids(touched_mailbox_ids)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Cache invalidation is skipped when a batch fails mid-sweep.

invalidate_mailbox_storage_ids(touched_mailbox_ids) runs only after the loop completes. If one batch raises (e.g. ProtectedError/DB error), already-deleted batches leave stale cached usage until the TTL expires, and the retry won't know the earlier mailbox ids. Invalidating per batch (or in a try/finally) keeps the gauge consistent with what was actually deleted.

♻️ Proposed change
-    while True:
-        batch_ids = list(base_qs.values_list("pk", flat=True)[:batch_size])
-        if not batch_ids:
-            break
-        # Collected before the delete: the ThreadAccess rows are unreachable
-        # through the messages once they are gone.
-        touched_mailbox_ids.update(
-            models.ThreadAccess.objects.filter(
-                thread__messages__pk__in=batch_ids
-            ).values_list("mailbox_id", flat=True)
-        )
-        deleted_count += permanently_delete_messages(
-            models.Message.objects.filter(pk__in=batch_ids)
-        )
-
-    invalidate_mailbox_storage_ids(touched_mailbox_ids)
+    try:
+        while True:
+            batch_ids = list(base_qs.values_list("pk", flat=True)[:batch_size])
+            if not batch_ids:
+                break
+            # Collected before the delete: the ThreadAccess rows are unreachable
+            # through the messages once they are gone.
+            batch_mailbox_ids = set(
+                models.ThreadAccess.objects.filter(
+                    thread__messages__pk__in=batch_ids
+                ).values_list("mailbox_id", flat=True)
+            )
+            touched_mailbox_ids.update(batch_mailbox_ids)
+            deleted_count += permanently_delete_messages(
+                models.Message.objects.filter(pk__in=batch_ids)
+            )
+            invalidate_mailbox_storage_ids(batch_mailbox_ids)
+    finally:
+        invalidate_mailbox_storage_ids(touched_mailbox_ids)
📝 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
while True:
batch_ids = list(base_qs.values_list("pk", flat=True)[:batch_size])
if not batch_ids:
break
# Collected before the delete: the ThreadAccess rows are unreachable
# through the messages once they are gone.
touched_mailbox_ids.update(
models.ThreadAccess.objects.filter(
thread__messages__pk__in=batch_ids
).values_list("mailbox_id", flat=True)
)
deleted_count += permanently_delete_messages(
models.Message.objects.filter(pk__in=batch_ids)
)
invalidate_mailbox_storage_ids(touched_mailbox_ids)
try:
while True:
batch_ids = list(base_qs.values_list("pk", flat=True)[:batch_size])
if not batch_ids:
break
# Collected before the delete: the ThreadAccess rows are unreachable
# through the messages once they are gone.
batch_mailbox_ids = set(
models.ThreadAccess.objects.filter(
thread__messages__pk__in=batch_ids
).values_list("mailbox_id", flat=True)
)
touched_mailbox_ids.update(batch_mailbox_ids)
deleted_count += permanently_delete_messages(
models.Message.objects.filter(pk__in=batch_ids)
)
invalidate_mailbox_storage_ids(batch_mailbox_ids)
finally:
invalidate_mailbox_storage_ids(touched_mailbox_ids)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/services/trashbin.py` around lines 160 - 175, Update the
batch sweep around permanently_delete_messages so mailbox storage cache
invalidation occurs for each successfully processed batch, or is guaranteed via
try/finally when the loop aborts. Preserve touched_mailbox_ids for all completed
deletions so a later failure cannot skip invalidation of earlier batches.

Comment on lines +1082 to +1088
# Who may manually empty the trashbin: "never" (only the cutoff sweep
# deletes), "admins" (mailbox role ADMIN), or "editors" (role >= EDITOR).
# Validated on read, against ``core.enums.TrashbinAllowEmpty`` — an
# unrecognised value raises rather than silently meaning "nobody".
TRASHBIN_ALLOW_EMPTY = values.Value(
"admins", environ_name="TRASHBIN_ALLOW_EMPTY", environ_prefix=None
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate TRASHBIN_ALLOW_EMPTY during startup. The application currently boots with an invalid policy and raises only when Mailbox.get_abilities() is later evaluated; that can turn a configuration error into request-time failures.

  • src/backend/messages/settings.py#L1082-L1088: validate against TrashbinAllowEmpty in startup configuration (while retaining runtime validation if override_settings support is required).
  • docs/env.md#L413-L413: keep the startup-rejection claim only once the setting is validated during startup.
📍 Affects 2 files
  • src/backend/messages/settings.py#L1082-L1088 (this comment)
  • docs/env.md#L413-L413
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/messages/settings.py` around lines 1082 - 1088, The
TRASHBIN_ALLOW_EMPTY setting currently defers invalid-value detection until
runtime; validate it against TrashbinAllowEmpty during startup configuration
while retaining runtime validation if override_settings requires it. Update
src/backend/messages/settings.py lines 1082-1088 at TRASHBIN_ALLOW_EMPTY, and
update docs/env.md line 413 to retain the startup-rejection claim only after
startup validation is implemented.

"You and {{assignees}} were unassigned_other": "Vous et {{assignees}} avez été désassigné·e·s",
"You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "Vous et tous les utilisateurs avec un accès à la boîte « {{mailboxName}} » ne pourront plus voir cette conversation.",
"You are about to leave this page and be redirected to:": "Vous êtes sur le point de quitter cette page et d'être redirigé vers :",
"You are about to permanently delete every message in the spam folder. This cannot be undone.": "Vous êtes sur le point de supprimer définitivement tous les messages du dossier indésirables. Cette action est irréversible.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the French spam-folder label.

Use “dossier des indésirables” rather than “dossier indésirables.”

Proposed fix
-  "You are about to permanently delete every message in the spam folder. This cannot be undone.": "Vous êtes sur le point de supprimer définitivement tous les messages du dossier indésirables. Cette action est irréversible.",
+  "You are about to permanently delete every message in the spam folder. This cannot be undone.": "Vous êtes sur le point de supprimer définitivement tous les messages du dossier des indésirables. Cette action est irréversible.",
📝 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
"You are about to permanently delete every message in the spam folder. This cannot be undone.": "Vous êtes sur le point de supprimer définitivement tous les messages du dossier indésirables. Cette action est irréversible.",
"You are about to permanently delete every message in the spam folder. This cannot be undone.": "Vous êtes sur le point de supprimer définitivement tous les messages du dossier des indésirables. Cette action est irréversible.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/public/locales/common/fr-FR.json` at line 1043, Update the
French translation for “You are about to permanently delete every message in the
spam folder. This cannot be undone.” to use “dossier des indésirables” instead
of “dossier indésirables,” preserving the rest of the translation.

Comment on lines +198 to +201
<span
className="mailbox-settings__storage-item-status"
aria-label={thread.is_unread ? t("Unread") : t("Read")}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

aria-label on a plain <span> is not exposed to assistive tech.

The status dot has no role, so the read/unread label is dropped by most screen readers. Give the element a role (role="img") or move the text into a visually hidden child.

♿ Proposed fix
                   <span
                     className="mailbox-settings__storage-item-status"
+                    role="img"
                     aria-label={thread.is_unread ? t("Unread") : t("Read")}
                   />
📝 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
<span
className="mailbox-settings__storage-item-status"
aria-label={thread.is_unread ? t("Unread") : t("Read")}
/>
<span
className="mailbox-settings__storage-item-status"
role="img"
aria-label={thread.is_unread ? t("Unread") : t("Read")}
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/storage-tab/index.tsx`
around lines 198 - 201, Update the status indicator span in the mailbox settings
storage item to expose its existing read/unread aria-label to assistive
technology by adding role="img" (or an equivalent visually hidden text child),
while preserving the current label selection.

Comment on lines +81 to +83
onSuccess: (response) => {
const data = response.data as { deleted_count?: number };
const deletedCount = data?.deleted_count ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer the generated response type over a manual cast.

response.data as { deleted_count?: number } bypasses whatever type useMailboxesEmptyTrashCreate actually generates from the OpenAPI schema. If that schema already types the 200 response with deleted_count (matching the backend's Response({"success": True, "deleted_count": deleted_count})), using it directly avoids the cast silently masking any future contract drift.

#!/bin/bash
# Check the generated response type for the empty-trash mutation.
rg -n -A 15 'useMailboxesEmptyTrashCreate' --type=ts src/frontend/src/features/api/gen 2>/dev/null || \
fd -e ts . src/frontend/src/features/api/gen --exec rg -l 'EmptyTrash'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/src/features/message/use-empty-trash.tsx` around lines 81 - 83,
Update the onSuccess handler in use-empty-trash.tsx to use the generated
response type from useMailboxesEmptyTrashCreate directly, removing the manual `{
deleted_count?: number }` cast. Access deleted_count through response.data while
preserving the existing nullish fallback to 0.

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.

1 participant