diff --git a/docs/env.md b/docs/env.md index 0c080c88..2ae46eea 100644 --- a/docs/env.md +++ b/docs/env.md @@ -409,7 +409,8 @@ without redeploying the frontend (the flag is pulled from | Variable | Default | Description | Required | |----------|---------|-------------|----------| -| `TRASHBIN_CUTOFF_DAYS` | `30` | Days before permanent deletion | Optional | +| `TRASHBIN_CUTOFF_DAYS` | `30` | Days an item may sit in the trashbin (trashed **or** spam messages) before the nightly `cleanup_trashbin_task` **permanently deletes** it. Age is measured from when the message entered the bin (`trashed_at`), falling back to `created_at` for rows written before that was recorded. Set to `0` to disable automatic deletion entirely. | Optional | +| `TRASHBIN_ALLOW_EMPTY` | `admins` | Who may manually empty a trashbin folder from the UI, an irreversible bulk delete: `never` (only the nightly sweep deletes), `admins` (mailbox role `ADMIN`), or `editors` (role `EDITOR` and above). Any other value is rejected at startup. | Optional | | `INVITATION_VALIDITY_DURATION` | `604800` | Invitation validity (7 days) | Optional | | `MESSAGES_MANUAL_RETRY_MAX_AGE`| `604800` | Maximum age in seconds for a message to be eligible for manual retry of failed deliveries (7 days) | Optional | | `MESSAGES_INBOUND_DEFERRAL_MAX_AGE` | `172800` | Maximum age in seconds an inbound message is deferred (retried every 5 min) when a processing step keeps failing, before the pipeline delivers it anyway (recorded as `postmark["processing"]`) rather than holding it indefinitely (48 hours) | Optional | @@ -570,6 +571,7 @@ Pluggable backend deciding what a user/domain is entitled to. | `ENTITLEMENTS_BACKEND` | `core.entitlements.backends.local.LocalEntitlementsBackend` | Dotted path to the entitlements backend class. | Optional | | `ENTITLEMENTS_BACKEND_PARAMETERS` | `{}` | JSON parameters passed to the backend. | Optional | | `ENTITLEMENTS_CACHE_TIMEOUT` | `300` | Cache TTL (seconds) for entitlement lookups. | Optional | +| `STORAGE_USAGE_CACHE_TTL` | `60` | Cache TTL (seconds) for the computed storage usage of a mailbox or organization, as read by the quota gauge and the entitlements backends. The computation runs several correlated subqueries (~100 ms on a large mailbox), so it is not recomputed on every sidebar load. Emptying a trashbin and the nightly sweep both invalidate the affected mailboxes immediately; every other change (new mail, sends) shows up within this TTL. Set to `0` to always compute live. The `/metrics` endpoints ignore this and always compute live. | Optional | ### Message Import diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 2f7ee3e9..7def09c6 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -323,6 +323,11 @@ "type": "boolean", "readOnly": true }, + "TRASHBIN_CUTOFF_DAYS": { + "type": "integer", + "description": "Number of days after which trashed and spam messages are permanently and automatically deleted.", + "readOnly": true + }, "MESSAGES_MANUAL_RETRY_MAX_AGE": { "type": "integer", "description": "Maximum age in seconds for a message to be eligible for manual retry of failed deliveries", @@ -426,6 +431,7 @@ "FEATURE_MAILDOMAIN_MANAGE_ACCESSES", "FEATURE_THREAD_SPLIT", "FEATURE_MAILDOMAIN_MANAGE_TOTP", + "TRASHBIN_CUTOFF_DAYS", "MESSAGES_MANUAL_RETRY_MAX_AGE", "FRONTEND_SILENT_LOGIN_ENABLED", "PUSH_ENABLED" @@ -4122,6 +4128,96 @@ } } }, + "/api/v1.0/mailboxes/{id}/empty-trash/": { + "post": { + "operationId": "mailboxes_empty_trash_create", + "description": "Permanently delete trashed or spam messages in the mailbox (pick the folder with `scope`). Deletes the whole folder by default, or only the items named by `thread_ids` / `message_ids`. This cannot be undone. Allowed only to the roles named by the TRASHBIN_ALLOW_EMPTY policy.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "mailboxes" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxEmptyTrashRequestRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MailboxEmptyTrashRequestRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxEmptyTrashResponse" + } + } + }, + "description": "" + }, + "403": { + "description": "Emptying the trashbin is not allowed for your role (governed by TRASHBIN_ALLOW_EMPTY)." + } + } + } + }, + "/api/v1.0/mailboxes/{id}/entitlements/": { + "get": { + "operationId": "mailboxes_entitlements_retrieve", + "description": "Return storage entitlements (usage and limits) for the mailbox.\n\nQuotas live on the mailbox, not the user. Access is restricted to the\nmailbox's own members through ``get_object`` (the viewset queryset is\nalready filtered to the current user's mailboxes).\n\nWhen the entitlements backend is unavailable, the gauge is degraded\nrather than erroring the request: usage falls back to 0 and limits to\nnull, which hides the gauge on the frontend.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "mailboxes" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxEntitlements" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/mailboxes/{id}/search/": { "get": { "operationId": "mailboxes_search_list", @@ -4169,6 +4265,42 @@ } } }, + "/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.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "tags": [ + "mailboxes" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailboxStorageStats" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/maildomains/": { "get": { "operationId": "maildomains_list", @@ -8439,6 +8571,48 @@ "name" ] }, + "LargestThread": { + "type": "object", + "description": "Serializer for a thread entry in the mailbox storage stats.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Thread UUID" + }, + "subject": { + "type": "string", + "nullable": true, + "description": "Thread subject" + }, + "size": { + "type": "integer", + "description": "Total compressed blob size of the thread in bytes" + }, + "message_count": { + "type": "integer", + "description": "Number of messages in the thread" + }, + "messaged_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Date of the last message in the thread" + }, + "is_unread": { + "type": "boolean", + "description": "Whether the thread is unread for this mailbox" + } + }, + "required": [ + "id", + "is_unread", + "message_count", + "messaged_at", + "size", + "subject" + ] + }, "MailDomainAccessRoleChoices": { "type": "string", "enum": [ @@ -8752,6 +8926,10 @@ "import_messages": { "type": "boolean", "description": "Can import messages" + }, + "empty_trash": { + "type": "boolean", + "description": "Can empty the trashbin (trashed + spam)" } }, "required": [ @@ -8765,7 +8943,8 @@ "send_messages", "manage_labels", "manage_message_templates", - "import_messages" + "import_messages", + "empty_trash" ], "readOnly": true } @@ -9212,6 +9391,91 @@ "custom_attributes": {} } }, + "MailboxEmptyTrashRequestRequest": { + "type": "object", + "description": "Payload for the \"empty trashbin\" endpoint.\n\nThe trashbin is the union of trashed and spam messages; ``scope`` picks the\nfolder to act on (\"trashed\" or \"spam\") so the two are handled independently.\n\n``thread_ids`` / ``message_ids`` narrow the deletion to specific items.\nBoth omitted (the default) means the whole folder — note this is the\nopposite of ``ThreadBulkDeleteRequestSerializer``, where omitting every\ntarget is an error: there, \"no targets\" is a malformed request; here it is\nthe primary use case (\"Empty trash\").\n\nPermanently deleting one selected message and emptying the entire folder\nare the same privilege, so they deliberately share this one endpoint rather\nthan being split across two that could drift apart — see the note on\n``ThreadBulkDeleteRequestSerializer.BULK_DELETE_SCOPE_FILTERS``.", + "properties": { + "scope": { + "allOf": [ + { + "$ref": "#/components/schemas/MailboxEmptyTrashRequestScopeEnum" + } + ], + "description": "Which half of the trashbin to permanently delete: 'trashed' or 'spam'.\n\n* `trashed` - trashed\n* `spam` - spam" + }, + "thread_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Restrict the deletion to these threads. Omit to empty the whole folder." + }, + "message_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Restrict the deletion to these messages (still scope-filtered). Omit to empty the whole folder." + } + }, + "required": [ + "scope" + ] + }, + "MailboxEmptyTrashRequestScopeEnum": { + "enum": [ + "trashed", + "spam" + ], + "type": "string", + "description": "* `trashed` - trashed\n* `spam` - spam" + }, + "MailboxEmptyTrashResponse": { + "type": "object", + "description": "Response for the \"empty trashbin\" endpoint.", + "properties": { + "success": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer", + "description": "Number of messages permanently deleted." + } + }, + "required": [ + "deleted_count", + "success" + ] + }, + "MailboxEntitlements": { + "type": "object", + "description": "Storage entitlements for a mailbox, at the account and organization levels.", + "properties": { + "account": { + "allOf": [ + { + "$ref": "#/components/schemas/StorageEntitlement" + } + ], + "readOnly": true + }, + "organization": { + "allOf": [ + { + "$ref": "#/components/schemas/StorageEntitlement" + } + ], + "readOnly": true, + "nullable": true + } + }, + "required": [ + "account", + "organization" + ] + }, "MailboxLight": { "type": "object", "description": "Serializer for mailbox details in thread access.", @@ -9252,6 +9516,47 @@ "admin" ] }, + "MailboxStorageStats": { + "type": "object", + "description": "Serializer for mailbox storage statistics response.", + "properties": { + "total_storage": { + "type": "integer", + "description": "Total storage used by the mailbox in bytes" + }, + "trashed_storage": { + "type": "integer", + "description": "Storage used by trashed conversations in bytes" + }, + "spam_storage": { + "type": "integer", + "description": "Storage used by spam conversations in bytes" + }, + "message_count": { + "type": "integer", + "description": "Total number of messages in the mailbox" + }, + "thread_count": { + "type": "integer", + "description": "Total number of threads in the mailbox" + }, + "largest_threads": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LargestThread" + }, + "description": "Top 100 threads by storage size, ordered descending." + } + }, + "required": [ + "largest_threads", + "message_count", + "spam_storage", + "thread_count", + "total_storage", + "trashed_storage" + ] + }, "MaildomainAccessRead": { "type": "object", "description": "Serialize maildomain access information for read operations with nested user details.", @@ -10386,13 +10691,6 @@ "type": "string", "description": "* `ACCEPTED` - ACCEPTED\n* `DECLINED` - DECLINED\n* `TENTATIVE` - TENTATIVE" }, - "ScopeEnum": { - "enum": [ - "draft" - ], - "type": "string", - "description": "* `draft` - draft" - }, "ScopeLevelEnum": { "enum": [ "global", @@ -10466,6 +10764,27 @@ "type": "string", "description": "* `FAILURE` - FAILURE\n* `PENDING` - PENDING\n* `PROGRESS` - PROGRESS\n* `RECEIVED` - RECEIVED\n* `RETRY` - RETRY\n* `REVOKED` - REVOKED\n* `STARTED` - STARTED\n* `SUCCESS` - SUCCESS" }, + "StorageEntitlement": { + "type": "object", + "description": "Storage usage and limit for a single level (mailbox or organization).\n\n``max_storage`` is nullable: null means \"no known limit\" and the\nfrontend hides the corresponding gauge.", + "properties": { + "storage_used": { + "type": "integer", + "readOnly": true, + "description": "Bytes currently used." + }, + "max_storage": { + "type": "integer", + "readOnly": true, + "nullable": true, + "description": "Storage limit in bytes, or null when there is no limit." + } + }, + "required": [ + "max_storage", + "storage_used" + ] + }, "TaskStatusResponse": { "type": "object", "properties": { @@ -10840,7 +11159,7 @@ "scope": { "allOf": [ { - "$ref": "#/components/schemas/ScopeEnum" + "$ref": "#/components/schemas/ThreadBulkDeleteRequestScopeEnum" } ], "description": "Which messages to permanently delete. Only 'draft' (draft messages) is supported.\n\n* `draft` - draft" @@ -10866,6 +11185,13 @@ "scope" ] }, + "ThreadBulkDeleteRequestScopeEnum": { + "enum": [ + "draft" + ], + "type": "string", + "description": "* `draft` - draft" + }, "ThreadEvent": { "type": "object", "description": "Serialize thread event information.", diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 8a18500f..82bafe12 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -25,6 +25,7 @@ from core.services.blob_gc import schedule_for_gc from core.services.identity import keycloak as keycloak_service from core.services.importer.channel import merged_state from core.services.ssrf import SSRFValidationError, validate_hostname +from core.services.trashbin import TRASHBIN_SCOPES class CreateOnlyFieldsMixin: @@ -596,6 +597,137 @@ class MailboxLightSerializer(serializers.ModelSerializer): return None +# pylint: disable=abstract-method +class StorageEntitlementSerializer(serializers.Serializer): + """Storage usage and limit for a single level (mailbox or organization). + + ``max_storage`` is nullable: null means "no known limit" and the + frontend hides the corresponding gauge. + """ + + storage_used = serializers.IntegerField( + read_only=True, help_text="Bytes currently used." + ) + max_storage = serializers.IntegerField( + read_only=True, + allow_null=True, + help_text="Storage limit in bytes, or null when there is no limit.", + ) + + +# pylint: disable=abstract-method +class MailboxEntitlementsSerializer(serializers.Serializer): + """Storage entitlements for a mailbox, at the account and organization levels.""" + + account = StorageEntitlementSerializer(read_only=True) + organization = StorageEntitlementSerializer(read_only=True, allow_null=True) + + +# pylint: disable=abstract-method +class LargestThreadSerializer(serializers.Serializer): + """Serializer for a thread entry in the mailbox storage stats.""" + + id = serializers.UUIDField(help_text="Thread UUID") + subject = serializers.CharField(allow_null=True, help_text="Thread subject") + size = serializers.IntegerField( + help_text="Total compressed blob size of the thread in bytes" + ) + message_count = serializers.IntegerField( + help_text="Number of messages in the thread" + ) + messaged_at = serializers.DateTimeField( + allow_null=True, help_text="Date of the last message in the thread" + ) + is_unread = serializers.BooleanField( + help_text="Whether the thread is unread for this mailbox" + ) + + +# pylint: disable=abstract-method +class MailboxStorageStatsSerializer(serializers.Serializer): + """Serializer for mailbox storage statistics response.""" + + total_storage = serializers.IntegerField( + help_text="Total storage used by the mailbox in bytes" + ) + trashed_storage = serializers.IntegerField( + help_text="Storage used by trashed conversations in bytes" + ) + spam_storage = serializers.IntegerField( + help_text="Storage used by spam conversations in bytes" + ) + message_count = serializers.IntegerField( + help_text="Total number of messages in the mailbox" + ) + thread_count = serializers.IntegerField( + help_text="Total number of threads in the mailbox" + ) + largest_threads = LargestThreadSerializer( + many=True, + help_text="Top 100 threads by storage size, ordered descending.", + ) + + +class MailboxEmptyTrashRequestSerializer(serializers.Serializer): + """Payload for the "empty trashbin" endpoint. + + The trashbin is the union of trashed and spam messages; ``scope`` picks the + folder to act on ("trashed" or "spam") so the two are handled independently. + + ``thread_ids`` / ``message_ids`` narrow the deletion to specific items. + Both omitted (the default) means the whole folder — note this is the + opposite of ``ThreadBulkDeleteRequestSerializer``, where omitting every + target is an error: there, "no targets" is a malformed request; here it is + the primary use case ("Empty trash"). + + Permanently deleting one selected message and emptying the entire folder + are the same privilege, so they deliberately share this one endpoint rather + than being split across two that could drift apart — see the note on + ``ThreadBulkDeleteRequestSerializer.BULK_DELETE_SCOPE_FILTERS``. + """ + + scope = serializers.ChoiceField( + choices=TRASHBIN_SCOPES, + help_text=( + "Which half of the trashbin to permanently delete: 'trashed' or 'spam'." + ), + ) + thread_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + allow_empty=True, + default=list, + help_text=( + "Restrict the deletion to these threads. Omit to empty the whole folder." + ), + ) + message_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + allow_empty=True, + default=list, + help_text=( + "Restrict the deletion to these messages (still scope-filtered). " + "Omit to empty the whole folder." + ), + ) + + def create(self, validated_data): + """Validation-only serializer.""" + + def update(self, instance, validated_data): + """Validation-only serializer.""" + + +class MailboxEmptyTrashResponseSerializer(serializers.Serializer): + """Response for the "empty trashbin" endpoint.""" + + success = serializers.BooleanField() + deleted_count = serializers.IntegerField( + help_text="Number of messages permanently deleted." + ) + + class ReadMessageTemplateSerializer(serializers.ModelSerializer): """Serialize message templates with dynamic body field inclusion. @@ -3095,9 +3227,19 @@ class ThreadBulkDeleteRequestSerializer(serializers.Serializer): to permanently delete.""" # Scopes accepted by the bulk-delete endpoint, mapping each to the queryset - # filter selecting the messages whose rows get permanently removed. Only - # "draft" is exposed for now: trashed deletion is intentionally not offered - # until the product behavior for trashed messages is decided. + # filter selecting the messages whose rows get permanently removed. + # + # DRAFTS ONLY, deliberately. Do not add "trashed"/"spam" here: this action + # authorizes purely on per-thread edit rights, with no mailbox context and + # no ability check, so trashbin scopes added here would sit outside the + # TRASHBIN_ALLOW_EMPTY policy — a deployment set to "never" could be emptied + # one selection at a time. Deleting a single trashbin message is the same + # privilege as emptying the folder, so both live on the policy-gated mailbox + # "empty-trash" action (which takes thread_ids/message_ids for exactly this) + # — see MailboxEmptyTrashRequestSerializer and ``core.services.trashbin``. + # + # Drafts are unlike the trashbin: they have no trash stage and no retention + # policy to enforce, so edit rights are the whole authorization story. BULK_DELETE_SCOPE_FILTERS = {"draft": {"is_draft": True}} BULK_DELETE_SCOPES = list(BULK_DELETE_SCOPE_FILTERS) diff --git a/src/backend/core/api/viewsets/config.py b/src/backend/core/api/viewsets/config.py index eebac602..b88f374c 100644 --- a/src/backend/core/api/viewsets/config.py +++ b/src/backend/core/api/viewsets/config.py @@ -144,6 +144,16 @@ CONFIG_ENTRIES = ( {"type": "boolean"}, getter=is_mandatory_totp_enabled, ), + ConfigEntry( + "TRASHBIN_CUTOFF_DAYS", + { + "type": "integer", + "description": ( + "Number of days after which trashed and spam messages are " + "permanently and automatically deleted." + ), + }, + ), ConfigEntry( "MESSAGES_MANUAL_RETRY_MAX_AGE", { diff --git a/src/backend/core/api/viewsets/flag.py b/src/backend/core/api/viewsets/flag.py index 437e6b67..8c1bfcd3 100644 --- a/src/backend/core/api/viewsets/flag.py +++ b/src/backend/core/api/viewsets/flag.py @@ -1,6 +1,8 @@ """API ViewSet for changing flags on messages or threads.""" from django.db import transaction +from django.db.models import Case, DateTimeField, F, Value, When +from django.db.models.functions import Coalesce from django.utils import timezone from django.utils.dateparse import parse_datetime @@ -21,6 +23,40 @@ from .. import permissions # Define allowed flag types ALLOWED_FLAGS = ["unread", "starred", "trashed", "archived", "spam"] +# The two message flags that make up the trashbin, and the other half of the +# pair — ``trashed_at`` belongs to the pair, not to either flag alone. +_TRASHBIN_FLAG_SIBLING = {"trashed": "is_spam", "spam": "is_trashed"} + + +def trashbin_timestamp(flag, value, current_time): + """Build the ``trashed_at`` value for a trashbin flag toggle. + + ``trashed_at`` records when a message entered the *trashbin*, and the + trashbin is ``is_trashed OR is_spam`` (see ``core.services.trashbin``). It + therefore has to outlive either flag on its own, so toggling one half must + not write the column unconditionally: + + - Setting a flag keeps any timestamp already there (``Coalesce``), so a + message that has sat in the bin for 20 days does not restart its + retention clock just because the other half got flipped — nor can a user + postpone deletion indefinitely by toggling spam on and off. + - Clearing a flag only clears the timestamp when the *other* half is not + set. Otherwise the message stays in the bin with a NULL ``trashed_at``, + the cutoff sweep falls back to ageing it by ``created_at``, and old mail + is permanently deleted on the next nightly run instead of getting its + full grace period. + """ + sibling = _TRASHBIN_FLAG_SIBLING[flag] + if value: + return Coalesce( + F("trashed_at"), Value(current_time), output_field=DateTimeField() + ) + return Case( + When(**{sibling: True}, then=F("trashed_at")), + default=Value(None), + output_field=DateTimeField(), + ) + class ChangeFlagView(APIView): """ViewSet for changing flags on messages or threads.""" @@ -258,8 +294,8 @@ class ChangeFlagView(APIView): batch_update_data = {"updated_at": current_time} if flag == "trashed": batch_update_data["is_trashed"] = value - batch_update_data["trashed_at"] = ( - current_time if value else None + batch_update_data["trashed_at"] = trashbin_timestamp( + flag, value, current_time ) elif flag == "archived": batch_update_data["is_archived"] = value @@ -268,6 +304,12 @@ class ChangeFlagView(APIView): ) elif flag == "spam": batch_update_data["is_spam"] = value + # trashed_at is the "entered the trashbin" time; spam is + # half the trashbin (is_trashed OR is_spam), so it keys + # the cutoff sweep off this too. See services/trashbin. + batch_update_data["trashed_at"] = trashbin_timestamp( + flag, value, current_time + ) messages_to_update.update(**batch_update_data) @@ -303,8 +345,8 @@ class ChangeFlagView(APIView): batch_update_data = {"updated_at": current_time} if flag == "trashed": batch_update_data["is_trashed"] = value - batch_update_data["trashed_at"] = ( - current_time if value else None + batch_update_data["trashed_at"] = trashbin_timestamp( + flag, value, current_time ) elif flag == "archived": batch_update_data["is_archived"] = value @@ -313,6 +355,12 @@ class ChangeFlagView(APIView): ) elif flag == "spam": batch_update_data["is_spam"] = value + # trashed_at is the "entered the trashbin" time; spam is + # half the trashbin (is_trashed OR is_spam), so it keys + # the cutoff sweep off this too. See services/trashbin. + batch_update_data["trashed_at"] = trashbin_timestamp( + flag, value, current_time + ) # Note: Trashing or Archiving a thread might have other side effects (e.g., updating thread state) # This current logic only updates the is_trashed or is_archived flag on messages within. # If Thread model itself has state, update threads_to_process separately. diff --git a/src/backend/core/api/viewsets/mailbox.py b/src/backend/core/api/viewsets/mailbox.py index ac917e52..9063d78d 100644 --- a/src/backend/core/api/viewsets/mailbox.py +++ b/src/backend/core/api/viewsets/mailbox.py @@ -1,13 +1,27 @@ """API ViewSet for Mailbox model.""" -from django.db.models import OuterRef, Q, Subquery +from django.conf import settings +from django.db.models import Count, F, OuterRef, Q, Subquery, Sum, Value +from django.db.models.functions import Coalesce -from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + OpenApiTypes, + extend_schema, +) from rest_framework import mixins, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from core import models +from core import enums, models +from core.entitlements import ( + EntitlementsUnavailableError, + get_mailbox_entitlements, +) +from core.services.storage import compute_mailbox_storage_used +from core.services.trashbin import empty_trashbin from .. import permissions, serializers @@ -39,9 +53,9 @@ class MailboxViewSet( ) def get_permissions(self): - """Require mailbox-admin rights to edit; reading stays open to any - member of the mailbox.""" - if self.action == "partial_update": + """Require mailbox-admin rights to edit or read the storage breakdown; + other reads stay open to any member of the mailbox.""" + if self.action in ("partial_update", "storage"): return [ permissions.IsAuthenticated(), permissions.IsMailboxAdminObject(), @@ -107,3 +121,218 @@ class MailboxViewSet( serializer = serializers.MailboxLightSerializer(queryset, many=True) return Response(serializer.data) + + @extend_schema( + tags=["mailboxes"], + responses=serializers.MailboxEntitlementsSerializer, + ) + @action(detail=True, methods=["get"]) + def entitlements(self, request, **kwargs): + """Return storage entitlements (usage and limits) for the mailbox. + + Quotas live on the mailbox, not the user. Access is restricted to the + mailbox's own members through ``get_object`` (the viewset queryset is + already filtered to the current user's mailboxes). + + When the entitlements backend is unavailable, the gauge is degraded + rather than erroring the request: usage falls back to 0 and limits to + null, which hides the gauge on the frontend. + """ + mailbox = self.get_object() + try: + data = get_mailbox_entitlements(mailbox) + except EntitlementsUnavailableError: + data = { + "account": {"storage_used": 0, "max_storage": None}, + "organization": None, + } + serializer = serializers.MailboxEntitlementsSerializer(data) + return Response(serializer.data) + + @extend_schema( + tags=["mailboxes"], + responses=serializers.MailboxStorageStatsSerializer, + ) + @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)) + + # ``order_by()`` clears Blob's default Meta.ordering so it does not leak + # into the GROUP BY and split the per-thread sum into per-blob rows. + thread_size_subquery = Subquery( + models.Blob.objects.filter(messages__thread=OuterRef("pk")) + .order_by() + .values("messages__thread") + .annotate(total=Sum("size_compressed")) + .values("total")[:1] + ) + thread_draft_size_subquery = Subquery( + models.Blob.objects.filter(drafts__thread=OuterRef("pk")) + .order_by() + .values("drafts__thread") + .annotate(total=Sum("size_compressed")) + .values("total")[:1] + ) + thread_msg_count_subquery = Subquery( + models.Message.objects.filter(thread=OuterRef("pk")) + .order_by() + .values("thread") + .annotate(cnt=Count("pk")) + .values("cnt")[:1] + ) + # This mailbox's own read cursor on each thread; unread is derived below + # (same rule as the thread list: no cursor, or a message newer than it). + read_at_subquery = Subquery( + models.ThreadAccess.objects.filter( + thread=OuterRef("pk"), mailbox=mailbox + ).values("read_at")[:1] + ) + + 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_data = [ + { + "id": str(thread.id), + "subject": thread.subject, + "size": int(thread.total_size), + "message_count": int(thread.msg_count), + "messaged_at": thread.messaged_at, + "is_unread": bool( + thread.messaged_at + and ( + thread.access_read_at is None + or thread.messaged_at > thread.access_read_at + ) + ), + } + for thread in largest_threads + ] + + serializer = serializers.MailboxStorageStatsSerializer( + { + "total_storage": total_storage, + "trashed_storage": trashed_storage, + "spam_storage": spam_storage, + "message_count": message_count, + "thread_count": thread_count, + "largest_threads": largest_threads_data, + } + ) + return Response(serializer.data) + + @extend_schema( + tags=["mailboxes"], + request=serializers.MailboxEmptyTrashRequestSerializer, + responses={ + 200: serializers.MailboxEmptyTrashResponseSerializer, + 403: OpenApiResponse( + description=( + "Emptying the trashbin is not allowed for your role " + "(governed by TRASHBIN_ALLOW_EMPTY)." + ), + ), + }, + description=( + "Permanently delete trashed or spam messages in the mailbox (pick " + "the folder with `scope`). Deletes the whole folder by default, or " + "only the items named by `thread_ids` / `message_ids`. This cannot " + "be undone. Allowed only to the roles named by the " + "TRASHBIN_ALLOW_EMPTY policy." + ), + ) + @action(detail=True, methods=["post"], url_path="empty-trash") + def empty_trash(self, request, **kwargs): + """Permanently delete from one folder of the mailbox's trashbin. + + The trashbin is ``is_trashed OR is_spam``; ``scope`` selects which folder + ("trashed" or "spam"). With no ``thread_ids``/``message_ids`` the whole + folder is wiped; with them, only those items. + + Both cases go through one gate — the ``empty_trash`` mailbox ability, + which encodes the ``TRASHBIN_ALLOW_EMPTY`` policy — because permanently + deleting one selected message and emptying the folder are the same + privilege. Keeping them on a single action means the two can never drift + apart into different permission checks. + """ + mailbox = self.get_object() + + if not mailbox.get_abilities(request.user).get( + enums.MailboxAbilities.CAN_EMPTY_TRASH + ): + raise PermissionDenied( + "You are not allowed to permanently delete from this trashbin. " + "This action is irreversible and restricted by your " + "administrator's policy." + ) + + serializer = serializers.MailboxEmptyTrashRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + deleted_count = empty_trashbin( + mailbox, + serializer.validated_data["scope"], + request.user, + thread_ids=serializer.validated_data["thread_ids"], + message_ids=serializer.validated_data["message_ids"], + ) + return Response({"success": True, "deleted_count": deleted_count}) diff --git a/src/backend/core/api/viewsets/metrics.py b/src/backend/core/api/viewsets/metrics.py index 0b46a6cd..b38a29c0 100644 --- a/src/backend/core/api/viewsets/metrics.py +++ b/src/backend/core/api/viewsets/metrics.py @@ -18,13 +18,13 @@ from core.api.permissions import IsGlobalChannelMixin, channel_scope from core.enums import ChannelApiKeyScope from core.models import ( Attachment, - Blob, Mailbox, MailboxAccess, MailDomain, Message, MessageTemplate, ) +from core.services.storage import mailbox_storage_used_expr # name: threshold (in days) ACTIVE_USER_METRICS = { @@ -218,56 +218,6 @@ class MailboxUsageMetricsApiView(IsGlobalChannelMixin, APIView): Returns per-mailbox storage usage computed as: storage_used = messages_count * OVERHEAD + sum(blobs.size_compressed) """ - overhead = settings.METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE - - # Use subqueries to avoid cross-product issues. - # All blob sizes are counted through their message/attachment - # relationships (via ThreadAccess), NOT through blob.mailbox. - - messages_count_subquery = Subquery( - Message.objects.filter(thread__accesses__mailbox=OuterRef("pk")) - .order_by() - .values("thread__accesses__mailbox") - .annotate(cnt=Count("id", distinct=True)) - .values("cnt")[:1] - ) - - # Raw MIME blobs linked via Message.blob - mime_blobs_subquery = Subquery( - Blob.objects.filter(messages__thread__accesses__mailbox=OuterRef("pk")) - .order_by() - .values("messages__thread__accesses__mailbox") - .annotate(total=Sum("size_compressed")) - .values("total")[:1] - ) - - # Draft body blobs linked via Message.draft_blob - draft_blobs_subquery = Subquery( - Blob.objects.filter(drafts__thread__accesses__mailbox=OuterRef("pk")) - .order_by() - .values("drafts__thread__accesses__mailbox") - .annotate(total=Sum("size_compressed")) - .values("total")[:1] - ) - - # Attachment blobs linked via Attachment.mailbox - attachment_blobs_subquery = Subquery( - Attachment.objects.filter(mailbox=OuterRef("pk")) - .order_by() - .values("mailbox") - .annotate(total=Sum("blob__size_compressed")) - .values("total")[:1] - ) - - # Template/signature blobs linked via MessageTemplate.mailbox - template_blobs_subquery = Subquery( - MessageTemplate.objects.filter(mailbox=OuterRef("pk"), blob__isnull=False) - .order_by() - .values("mailbox") - .annotate(total=Sum("blob__size_compressed")) - .values("total")[:1] - ) - queryset = Mailbox.objects.select_related("domain") # Apply filters @@ -309,13 +259,7 @@ class MailboxUsageMetricsApiView(IsGlobalChannelMixin, APIView): status=400, ) - storage_expr = ( - Coalesce(messages_count_subquery, Value(0)) * overhead - + Coalesce(mime_blobs_subquery, Value(0)) - + Coalesce(draft_blobs_subquery, Value(0)) - + Coalesce(attachment_blobs_subquery, Value(0)) - + Coalesce(template_blobs_subquery, Value(0)) - ) + storage_expr = mailbox_storage_used_expr() # Build results based on account_type if account_type == "organization": diff --git a/src/backend/core/api/viewsets/thread.py b/src/backend/core/api/viewsets/thread.py index 90231823..645f3262 100644 --- a/src/backend/core/api/viewsets/thread.py +++ b/src/backend/core/api/viewsets/thread.py @@ -23,6 +23,7 @@ from core import enums, models from core.ai.thread_summarizer import summarize_thread from core.mda.utils import thread_snippet from core.services.search import search_threads +from core.services.trashbin import permanently_delete_messages from .. import permissions, serializers @@ -1099,31 +1100,17 @@ class ThreadViewSet( request.user ).values_list("thread_id", flat=True) - with transaction.atomic(): - messages_to_delete = models.Message.objects.filter( - thread_id__in=accessible_thread_ids, - **scope_filter, - ) - if thread_ids: - messages_to_delete = messages_to_delete.filter(thread_id__in=thread_ids) - if message_ids: - messages_to_delete = messages_to_delete.filter(id__in=message_ids) + messages_to_delete = models.Message.objects.filter( + thread_id__in=accessible_thread_ids, + **scope_filter, + ) + if thread_ids: + messages_to_delete = messages_to_delete.filter(thread_id__in=thread_ids) + if message_ids: + messages_to_delete = messages_to_delete.filter(id__in=message_ids) - affected_thread_ids = set( - messages_to_delete.values_list("thread_id", flat=True) - ) - # Count before deletion: the cascade total returned by delete() also - # includes related rows (recipients, attachments), not just messages. - deleted_count = messages_to_delete.count() - messages_to_delete.delete() - - # An emptied thread is removed; a thread that still has messages has - # its denormalized stats (has_draft, has_trashed, ...) recomputed so - # it drops out of the corresponding folder filter. - for thread in models.Thread.objects.filter(pk__in=affected_thread_ids): - if thread.messages.exists(): - thread.update_stats() - else: - thread.delete() + # Shared with the trashbin cutoff sweep and the "empty trashbin" action: + # hard-delete the rows, then drop emptied threads / recompute stats. + deleted_count = permanently_delete_messages(messages_to_delete) return drf.response.Response({"success": True, "deleted_count": deleted_count}) diff --git a/src/backend/core/entitlements/__init__.py b/src/backend/core/entitlements/__init__.py index 1bc8841b..703007c5 100644 --- a/src/backend/core/entitlements/__init__.py +++ b/src/backend/core/entitlements/__init__.py @@ -26,3 +26,23 @@ def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=Fa return backend.get_user_entitlements( user_sub, user_email, user_info=user_info, force_refresh=force_refresh ) + + +def get_mailbox_entitlements(mailbox, force_refresh=False): + """Get storage entitlements for a mailbox, delegating to the configured backend. + + Args: + mailbox: The Mailbox instance to resolve entitlements for. + force_refresh: If True, bypass backend cache and fetch fresh data. + + Returns: + dict: { + "account": {"storage_used": int, "max_storage": int | None}, + "organization": {"storage_used": int, "max_storage": int | None} | None, + } + + Raises: + EntitlementsUnavailableError: If the backend cannot be reached and no cache exists. + """ + backend = get_entitlements_backend() + return backend.get_mailbox_entitlements(mailbox, force_refresh=force_refresh) diff --git a/src/backend/core/entitlements/backends/base.py b/src/backend/core/entitlements/backends/base.py index be85ae4a..a8c9d0c8 100644 --- a/src/backend/core/entitlements/backends/base.py +++ b/src/backend/core/entitlements/backends/base.py @@ -27,3 +27,34 @@ class EntitlementsBackend(ABC): Raises: EntitlementsUnavailableError: If the backend cannot be reached. """ + + def get_mailbox_entitlements( # pylint: disable=unused-argument + self, mailbox, force_refresh=False + ): + """Fetch storage entitlements for a mailbox. + + Quotas are attached to mailboxes, not users: a user object never + carries a quota, so callers always resolve entitlements through the + mailbox they are viewing. + + The result carries two levels — the mailbox ("account") and, when the + mailbox's domain is tied to an organization, the aggregate for that + organization. A ``max_storage`` of ``None`` means "no limit known" + and the frontend hides the corresponding gauge. + + Returns: + dict: { + "account": {"storage_used": int, "max_storage": int | None}, + "organization": { + "storage_used": int, + "max_storage": int | None, + } | None, + } + + Raises: + EntitlementsUnavailableError: If the backend cannot be reached. + """ + return { + "account": {"storage_used": 0, "max_storage": None}, + "organization": None, + } diff --git a/src/backend/core/entitlements/backends/deploycenter.py b/src/backend/core/entitlements/backends/deploycenter.py index 8dd56303..159cddb0 100644 --- a/src/backend/core/entitlements/backends/deploycenter.py +++ b/src/backend/core/entitlements/backends/deploycenter.py @@ -9,6 +9,10 @@ import requests from core.entitlements import EntitlementsUnavailableError from core.entitlements.backends.base import EntitlementsBackend +from core.services.storage import ( + get_mailbox_storage_used, + get_organization_storage_used, +) logger = logging.getLogger(__name__) @@ -27,7 +31,14 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend): """ def __init__( - self, base_url, service_id, api_key, timeout=10, oidc_claims=None, **kwargs + self, + base_url, + service_id, + api_key, + timeout=10, + oidc_claims=None, + organization_claim="siret", + **kwargs, ): super().__init__(**kwargs) self.base_url = base_url @@ -35,10 +46,14 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend): self.api_key = api_key self.timeout = timeout self.oidc_claims = oidc_claims or [] + self.organization_claim = organization_claim def _cache_key(self, user_sub): return f"entitlements:user:{user_sub}" + def _mailbox_cache_key(self, mailbox_id): + return f"entitlements:mailbox:{mailbox_id}" + def _make_request(self, user_email, user_info=None): """Make a request to the DeployCenter entitlements API. @@ -111,3 +126,127 @@ class DeployCenterEntitlementsBackend(EntitlementsBackend): cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT) return result + + def _build_usage_metrics(self, mailbox, org_value): + """Build the usage-metric entries pushed to DeployCenter. + + Metrics are computed locally (same computation the global metrics + endpoint exposes) and pushed in the POST body so DeployCenter does not + have to scrape us back. The organization entry is only included when + the mailbox's domain is tied to an organization. + + ``storage_used`` here is the per-message "storage felt" basis (a + sha-shared blob counts once per referencing message), so DeployCenter's + quota decision uses the same basis as the in-app gauge. This is + intentional and settled — see ``core.services.storage``. + + Read through the cached accessors (``get_*``, TTL + ``STORAGE_USAGE_CACHE_TTL``) rather than recomputing. This runs on every + entitlements cache miss, inside a user-facing request, and the + organization figure aggregates the five-subquery annotation across every + mailbox in the organization — far too expensive to put in front of a + sidebar load. The cost is that DeployCenter may see a figure up to one + TTL stale, which is well inside the entitlements cache window it is + already being read back through. The ``/metrics`` scrape endpoints still + compute live. + """ + mailbox_email = f"{mailbox.local_part}@{mailbox.domain.name}" + mailbox_entry = { + "account": {"type": "mailbox", "email": mailbox_email}, + "metrics": {"storage_used": get_mailbox_storage_used(mailbox)}, + } + if org_value is None: + return [mailbox_entry] + + mailbox_entry[self.organization_claim] = org_value + organization_entry = { + "account": {"type": "organization"}, + "metrics": { + "storage_used": get_organization_storage_used( + self.organization_claim, org_value + ) + }, + self.organization_claim: org_value, + } + return [mailbox_entry, organization_entry] + + def _fetch_mailbox_entitlements(self, mailbox, org_value): + """POST usage metrics and read back storage limits for a mailbox. + + Returns the parsed DeployCenter response, or None on failure. + """ + mailbox_email = f"{mailbox.local_part}@{mailbox.domain.name}" + params = { + "account_type": "mailbox", + "account_email": mailbox_email, + "service_id": self.service_id, + } + if org_value is not None: + params[self.organization_claim] = org_value + + headers = {"X-Service-Auth": f"Bearer {self.api_key}"} + + try: + response = requests.post( + self.base_url, + params=params, + json={"usage_metrics": self._build_usage_metrics(mailbox, org_value)}, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() + except (requests.RequestException, ValueError): + logger.warning( + "DeployCenter mailbox entitlements request failed for %s", + mailbox.domain.name, + exc_info=True, + ) + return None + + def get_mailbox_entitlements(self, mailbox, force_refresh=False): + """Fetch storage entitlements for a mailbox from DeployCenter, cached. + + On API failure, falls back to a stale cache if available, otherwise + raises EntitlementsUnavailableError. + """ + cache_key = self._mailbox_cache_key(mailbox.id) + + if not force_refresh: + cached = cache.get(cache_key) + if cached is not None: + return cached + + org_value = (mailbox.domain.custom_attributes or {}).get( + self.organization_claim + ) + data = self._fetch_mailbox_entitlements(mailbox, org_value) + + if data is None: + cached = cache.get(cache_key) + if cached is not None: + return cached + raise EntitlementsUnavailableError( + "Failed to fetch mailbox entitlements from DeployCenter" + ) + + entitlements = data.get("entitlements", {}) + metrics = data.get("metrics", {}) + + account = { + "storage_used": (metrics.get("account") or {}).get("storage_used", 0), + "max_storage": entitlements.get("max_storage_account"), + } + + organization = None + if org_value is not None: + organization = { + "storage_used": (metrics.get("organization") or {}).get( + "storage_used", 0 + ), + "max_storage": entitlements.get("max_storage_organization"), + } + + result = {"account": account, "organization": organization} + cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT) + return result diff --git a/src/backend/core/entitlements/backends/local.py b/src/backend/core/entitlements/backends/local.py index 3485be45..70dfeb0f 100644 --- a/src/backend/core/entitlements/backends/local.py +++ b/src/backend/core/entitlements/backends/local.py @@ -1,11 +1,41 @@ """Local entitlements backend for development and testing.""" from core.entitlements.backends.base import EntitlementsBackend +from core.services.storage import ( + get_mailbox_storage_used, + get_organization_storage_used, +) + +# Default per-mailbox storage limit (bytes) surfaced by the gauge in local +# development. Kept as a visible, round value so the widget is exercised +# out of the box; production deployments use the DeployCenter backend. +DEFAULT_MAILBOX_STORAGE_LIMIT = 5 * 1000**3 # 5 GB class LocalEntitlementsBackend(EntitlementsBackend): - """Local backend that always grants access. Returns None for - can_admin_maildomains to signal that domain admin sync is not supported.""" + """Local backend that always grants access and computes storage from the DB. + + ``can_admin_maildomains`` is None to signal that domain admin sync is not + supported. Storage usage is computed from local data; the limits are + static config so the quota gauge can be exercised without DeployCenter. + """ + + def __init__( + self, + mailbox_storage_limit=DEFAULT_MAILBOX_STORAGE_LIMIT, + organization_storage_limit=None, + organization_claim="siret", + ): + # ``None`` means "no limit" — the frontend then hides that gauge. + self.mailbox_storage_limit = ( + int(mailbox_storage_limit) if mailbox_storage_limit is not None else None + ) + self.organization_storage_limit = ( + int(organization_storage_limit) + if organization_storage_limit is not None + else None + ) + self.organization_claim = organization_claim def get_user_entitlements( self, user_sub, user_email, user_info=None, force_refresh=False @@ -14,3 +44,23 @@ class LocalEntitlementsBackend(EntitlementsBackend): "can_access": True, "can_admin_maildomains": None, } + + 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} diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index a8708583..24cce9af 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -208,6 +208,7 @@ class MailboxAbilities(models.TextChoices): "Can manage mailbox message templates", ) CAN_IMPORT_MESSAGES = "import_messages", "Can import messages" + CAN_EMPTY_TRASH = "empty_trash", "Can empty the trashbin (trashed + spam)" class ThreadAbilities(models.TextChoices): @@ -549,3 +550,24 @@ class PreviewRefusalCode(StrEnum): SUSPICIOUS = "suspicious" UNSUPPORTED = "unsupported" + + +class TrashbinAllowEmpty(StrEnum): + """Accepted values for ``settings.TRASHBIN_ALLOW_EMPTY``. + + Who may manually empty a trashbin folder — an irreversible bulk delete of + every trashed (or spam) message in a mailbox. Independent of the nightly + cutoff sweep, which always runs. + + Deliberately a plain ``StrEnum`` rather than a ``models.TextChoices``: this + is a deployment policy read from settings and never stored on a model + field, so it stays out of the ORM entirely. Adding a member here is a code + change with no schema impact and generates no migration. + """ + + #: Nobody; only the nightly cutoff sweep deletes. + NEVER = "never" + #: Mailbox role ADMIN. + ADMINS = "admins" + #: Mailbox role EDITOR and above. + EDITORS = "editors" diff --git a/src/backend/core/mda/inbound_create.py b/src/backend/core/mda/inbound_create.py index 2295ae25..de21baff 100644 --- a/src/backend/core/mda/inbound_create.py +++ b/src/backend/core/mda/inbound_create.py @@ -501,8 +501,10 @@ def _create_message_from_inbound( # pylint: disable=too-many-arguments # Keep timestamps in lockstep with the booleans, as the # flag endpoint does — a NULL trashed_at/archived_at on a # trashed/archived row breaks restore, ordering and any - # auto-purge that keys off the timestamp. - trashed_at=(timezone.now() if is_trashed else None), + # auto-purge that keys off the timestamp. ``trashed_at`` is + # the "entered the trashbin" time, so spam sets it too (the + # trashbin is is_trashed OR is_spam; see services/trashbin). + trashed_at=(timezone.now() if (is_trashed or is_spam) else None), is_archived=is_archived, archived_at=(timezone.now() if is_archived else None), is_spam=is_spam, @@ -520,12 +522,23 @@ def _create_message_from_inbound( # pylint: disable=too-many-arguments for flag, value in message_flags.items(): if hasattr(message, flag): setattr(message, flag, value) - message.save( - update_fields=[ - "created_at", - *message_flags.keys(), - ] - ) + update_fields = ["created_at", *message_flags.keys()] + + # The importer carries Trash/Spam through ``message_flags`` (IMAP + # labels), NOT through the ``is_trashed``/``is_spam`` arguments — + # those stay False for imports, so the ``trashed_at`` computed at + # create() time above is still NULL here. Stamp it now, from the + # import rather than from the message's own date: ``created_at`` + # was just backdated to ``sent_at``, so ageing an imported bin + # item by it would put a five-year-old Spam folder instantly past + # TRASHBIN_CUTOFF_DAYS and hand the whole thing to the first + # nightly sweep. Dating from the import gives it the full grace + # period. See core/services/trashbin. + if message.is_trashed or message.is_spam: + message.trashed_at = timezone.now() + update_fields.append("trashed_at") + + message.save(update_fields=update_fields) # Update ThreadAccess for read/starred state access = models.ThreadAccess.objects.filter( thread=thread, mailbox=mailbox diff --git a/src/backend/core/migrations/0035_message_msg_trashbin_cutoff_idx.py b/src/backend/core/migrations/0035_message_msg_trashbin_cutoff_idx.py new file mode 100644 index 00000000..4f349897 --- /dev/null +++ b/src/backend/core/migrations/0035_message_msg_trashbin_cutoff_idx.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0034_channel_lookup_hash"), + ] + + operations = [ + migrations.AddIndex( + model_name="message", + index=models.Index( + condition=models.Q(("is_trashed", True), ("is_spam", True), _connector="OR"), + fields=["trashed_at", "created_at"], + name="msg_trashbin_cutoff_idx", + ), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index eeb704e3..19b69fad 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -20,7 +20,7 @@ from django.conf import settings from django.contrib.auth import models as auth_models from django.contrib.auth.base_user import AbstractBaseUser from django.core import validators -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db import connection, models, transaction from django.db.models import Case, Exists, F, Q, Value, When from django.db.models.fields import BooleanField @@ -52,6 +52,7 @@ from core.enums import ( ThreadAbilities, ThreadAccessRoleChoices, ThreadEventTypeChoices, + TrashbinAllowEmpty, UserAbilities, parse_compression_spec, thread_event_type_choices, @@ -957,6 +958,7 @@ class Mailbox(BaseModel): "manage_labels": False, "manage_message_templates": False, "import_messages": False, + "empty_trash": False, } is_admin = role == MailboxRoleChoices.ADMIN @@ -965,6 +967,26 @@ class Mailbox(BaseModel): can_send = role >= MailboxRoleChoices.SENDER has_access = bool(role) + # Who may manually empty the trashbin depends on the deployment policy: + # "admins" -> ADMIN only, "editors" -> EDITOR and above, "never" -> nobody + # (the cutoff sweep still deletes). See settings.TRASHBIN_ALLOW_EMPTY. + # + # Validated on read, not at settings-parse time. An unrecognised value + # would otherwise match no branch below and silently collapse to + # "nobody may empty" — indistinguishable from a deliberate "never", and + # invisible in the logs. Checking here also covers a value injected + # after startup (override_settings, runtime reconfiguration), which a + # parse-time check cannot see. + trashbin_allow_empty = settings.TRASHBIN_ALLOW_EMPTY + if trashbin_allow_empty not in set(TrashbinAllowEmpty): + raise ImproperlyConfigured( + f"TRASHBIN_ALLOW_EMPTY is {trashbin_allow_empty!r}: must be one " + f"of {', '.join(sorted(TrashbinAllowEmpty))}" + ) + can_empty_trash = ( + trashbin_allow_empty == TrashbinAllowEmpty.ADMINS and is_admin + ) or (trashbin_allow_empty == TrashbinAllowEmpty.EDITORS and can_modify) + return { CRUDAbilities.CAN_READ: has_access, CRUDAbilities.CAN_CREATE: can_modify, @@ -981,6 +1003,7 @@ class Mailbox(BaseModel): MailboxAbilities.CAN_IMPORT_MESSAGES: ( is_admin and settings.FEATURE_IMPORT_MESSAGES ), + MailboxAbilities.CAN_EMPTY_TRASH: can_empty_trash, } def get_validated_signature(self, signature_id: str): @@ -2168,6 +2191,21 @@ class Message(BaseModel): verbose_name = "message" verbose_name_plural = "messages" ordering = ["-created_at"] + indexes = [ + # Backs the daily trashbin cutoff sweep (cleanup_trashbin_task). + # PARTIAL on purpose: the trashbin (is_trashed OR is_spam) is a small, + # bounded set — items older than TRASHBIN_CUTOFF_DAYS are deleted — so + # this index stays tiny and cheap to maintain (only trash/spam events + # touch it) even as the message table grows unbounded. Without it the + # daily sweep would seq-scan the whole table to find that small set, + # a cost that scales with total messages forever. Columns are the + # ones it ages by, Coalesce(trashed_at, created_at). + models.Index( + fields=["trashed_at", "created_at"], + name="msg_trashbin_cutoff_idx", + condition=models.Q(is_trashed=True) | models.Q(is_spam=True), + ), + ] def __str__(self): return str(self.subject) if self.subject else "(no subject)" diff --git a/src/backend/core/services/storage.py b/src/backend/core/services/storage.py new file mode 100644 index 00000000..9aef729e --- /dev/null +++ b/src/backend/core/services/storage.py @@ -0,0 +1,205 @@ +"""Storage-usage computation shared by the metrics API and the +mailbox entitlements/quota backends. + +Storage for a mailbox is the sum of: + +- ``messages_count * OVERHEAD`` — a flat per-message overhead + (``METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE``) standing in for the + Postgres row/index cost that is not captured by blob sizes; +- the compressed size of every blob reachable from the mailbox through + its threads (raw MIME bodies and draft bodies) and its own attachments + and message templates. + +All blob sizes are counted through the message/attachment relationships +(via ``ThreadAccess``), never through ``blob.mailbox``. This is the exact +computation the global metrics endpoint exposes; keeping it in one place +means the in-app quota gauge and the external metrics scrape can never +drift apart. + +SHARED-BLOB BASIS (settled — do not "fix" this to dedupe). +Blobs are sha-deduplicated, so one Blob row can back several messages (most +plausibly two drafts with identical bodies, or a re-imported message). This +per-mailbox figure counts such a blob ONCE PER REFERENCING MESSAGE — the +"storage felt" by the mailbox, not the physical bytes on disk. That is the +intended basis: + +- It is pinned by ``test_blobs_with_identical_sizes_counted_separately`` and is + the same number reported to the entitlements API (``deploycenter`` POSTs it in + ``_build_usage_metrics``), so it is the quota basis on both sides. +- The plain ``Sum`` below is therefore correct on purpose. The *per-domain* + metrics endpoint answers a different question (physical bytes across a whole + domain) and deliberately deduplicates with ``SELECT DISTINCT b.id`` — that + asymmetry is intentional, not a bug. Measured impact of the difference on real + data is nil (normal received mail never shares a blob within one mailbox). +""" + +from django.conf import settings +from django.core.cache import cache +from django.db.models import Count, OuterRef, Subquery, Sum, Value +from django.db.models.functions import Coalesce + +from core.models import Attachment, Blob, Mailbox, Message, MessageTemplate + + +def mailbox_storage_used_expr(overhead=None): + """Return a Django expression computing ``storage_used`` for a mailbox. + + Meant to annotate a ``Mailbox`` queryset (``OuterRef("pk")`` resolves to + the mailbox row). Subqueries are used to avoid the cross-product a naive + multi-join aggregate would produce. + + Args: + overhead: Per-message overhead in bytes. Defaults to + ``settings.METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE``. + """ + if overhead is None: + overhead = settings.METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE + + messages_count_subquery = Subquery( + Message.objects.filter(thread__accesses__mailbox=OuterRef("pk")) + .order_by() + .values("thread__accesses__mailbox") + .annotate(cnt=Count("id", distinct=True)) + .values("cnt")[:1] + ) + + # Raw MIME blobs linked via Message.blob + mime_blobs_subquery = Subquery( + Blob.objects.filter(messages__thread__accesses__mailbox=OuterRef("pk")) + .order_by() + .values("messages__thread__accesses__mailbox") + .annotate(total=Sum("size_compressed")) + .values("total")[:1] + ) + + # Draft body blobs linked via Message.draft_blob + draft_blobs_subquery = Subquery( + Blob.objects.filter(drafts__thread__accesses__mailbox=OuterRef("pk")) + .order_by() + .values("drafts__thread__accesses__mailbox") + .annotate(total=Sum("size_compressed")) + .values("total")[:1] + ) + + # Attachment blobs linked via Attachment.mailbox + attachment_blobs_subquery = Subquery( + Attachment.objects.filter(mailbox=OuterRef("pk")) + .order_by() + .values("mailbox") + .annotate(total=Sum("blob__size_compressed")) + .values("total")[:1] + ) + + # Template/signature blobs linked via MessageTemplate.mailbox + template_blobs_subquery = Subquery( + MessageTemplate.objects.filter(mailbox=OuterRef("pk"), blob__isnull=False) + .order_by() + .values("mailbox") + .annotate(total=Sum("blob__size_compressed")) + .values("total")[:1] + ) + + return ( + Coalesce(messages_count_subquery, Value(0)) * overhead + + Coalesce(mime_blobs_subquery, Value(0)) + + Coalesce(draft_blobs_subquery, Value(0)) + + Coalesce(attachment_blobs_subquery, Value(0)) + + Coalesce(template_blobs_subquery, Value(0)) + ) + + +def compute_mailbox_storage_used(mailbox): + """Return the storage used, in bytes, by a single mailbox.""" + return ( + Mailbox.objects.filter(pk=mailbox.pk) + .annotate(storage_used=mailbox_storage_used_expr()) + .values_list("storage_used", flat=True) + .first() + ) or 0 + + +def compute_organization_storage_used(account_id_key, account_id_value): + """Return the storage used, in bytes, by an organization. + + The organization is the set of mailboxes whose domain carries + ``custom_attributes[account_id_key] == account_id_value`` (e.g. all + mailboxes of every maildomain sharing the same SIRET). + """ + return ( + Mailbox.objects.filter( + **{f"domain__custom_attributes__{account_id_key}": account_id_value} + ) + .annotate(storage_used=mailbox_storage_used_expr()) + .aggregate(total=Coalesce(Sum("storage_used"), Value(0)))["total"] + ) + + +# --------------------------------------------------------------------------- +# Cached accessors +# +# The ``compute_*`` functions above run five correlated subqueries and take on +# the order of 100ms for a large mailbox, so the quota gauge/entitlements read +# through a short-TTL cache instead of recomputing on every sidebar load. The +# *metrics* endpoints deliberately keep calling the ``compute_*`` (live), since +# a scrape wants a current number and runs rarely. +# +# The TTL is the workhorse: it bounds staleness for the up-direction (new mail, +# sends) without touching those hot paths. ``invalidate_mailbox_storage`` is the +# nicety for the one down-event a user actively watches — emptying the trashbin +# — so the freed space shows immediately rather than after the TTL. The +# organization total is left to the TTL (it would need the backend-specific +# claim to key precisely, and a ~minute lag on the coarser org gauge is fine). +# --------------------------------------------------------------------------- + + +def _mailbox_storage_cache_key(mailbox_id): + return f"storage_used:mailbox:{mailbox_id}" + + +def _organization_storage_cache_key(account_id_key, account_id_value): + return f"storage_used:org:{account_id_key}:{account_id_value}" + + +def get_mailbox_storage_used(mailbox): + """Cached ``compute_mailbox_storage_used``; see module note above.""" + ttl = settings.STORAGE_USAGE_CACHE_TTL + if not ttl: + return compute_mailbox_storage_used(mailbox) + return cache.get_or_set( + _mailbox_storage_cache_key(mailbox.pk), + lambda: compute_mailbox_storage_used(mailbox), + ttl, + ) + + +def get_organization_storage_used(account_id_key, account_id_value): + """Cached ``compute_organization_storage_used``; see module note above.""" + ttl = settings.STORAGE_USAGE_CACHE_TTL + if not ttl: + return compute_organization_storage_used(account_id_key, account_id_value) + return cache.get_or_set( + _organization_storage_cache_key(account_id_key, account_id_value), + lambda: compute_organization_storage_used(account_id_key, account_id_value), + ttl, + ) + + +def invalidate_mailbox_storage(mailbox): + """Drop a mailbox's cached storage usage (e.g. after emptying its trashbin). + + Only the mailbox entry is dropped; the organization total falls back to its + TTL (keying it precisely would need the backend's org claim). + """ + cache.delete(_mailbox_storage_cache_key(mailbox.pk)) + + +def invalidate_mailbox_storage_ids(mailbox_ids): + """``invalidate_mailbox_storage`` for many mailboxes, keyed by id. + + Used by the nightly cutoff sweep, which frees space across many mailboxes + at once and never loads the Mailbox rows themselves. One ``delete_many`` + rather than a delete per mailbox. + """ + keys = [_mailbox_storage_cache_key(mailbox_id) for mailbox_id in mailbox_ids] + if keys: + cache.delete_many(keys) diff --git a/src/backend/core/services/trashbin.py b/src/backend/core/services/trashbin.py new file mode 100644 index 00000000..0e6f6071 --- /dev/null +++ b/src/backend/core/services/trashbin.py @@ -0,0 +1,184 @@ +"""Trashbin service: permanent deletion of trashed and spam messages. + +The **trashbin** is the union of trashed and spam messages — +``is_trashed OR is_spam``. The product treats the two the same way: both are +"in the bin", both are surfaced by their own sidebar folder (Trash / Spam), and +both are permanently removed from here. + +Two things empty the bin: + +- ``cleanup_trashbin_task`` — a daily sweep that hard-deletes items older than + ``settings.TRASHBIN_CUTOFF_DAYS``. +- ``empty_trashbin`` — a manual, per-folder "empty now" triggered from the UI + (gated by ``settings.TRASHBIN_ALLOW_EMPTY`` at the API layer). + +Deleting a ``Message`` cascades to its attachments and schedules its blobs for +GC via ``post_delete`` signals (see ``core/signals.py``); the hourly blob GC +task reclaims the underlying storage. Nothing extra is needed here. +""" + +from datetime import timedelta + +from django.conf import settings +from django.db import transaction +from django.db.models import Q +from django.db.models.functions import Coalesce +from django.utils import timezone + +from celery.utils.log import get_task_logger + +from core import models +from core.services.storage import ( + invalidate_mailbox_storage, + invalidate_mailbox_storage_ids, +) + +from messages.celery_app import app as celery_app + +logger = get_task_logger(__name__) + +# Message filters selecting each half of the trashbin. Kept per-folder ("trashed" +# vs "spam") so the manual empty can act on exactly the folder the user is in. +TRASHBIN_SCOPE_FILTERS = { + "trashed": Q(is_trashed=True), + "spam": Q(is_spam=True), +} +TRASHBIN_SCOPES = list(TRASHBIN_SCOPE_FILTERS) + + +def permanently_delete_messages(messages_qs): + """Hard-delete the given messages and reconcile their threads. + + A thread emptied by the deletion is removed; a thread that still has + messages has its denormalized stats recomputed so it drops out of the + folder filters. Mirrors ``ThreadViewSet.bulk_delete``. + + Returns the number of messages deleted. + """ + with transaction.atomic(): + affected_thread_ids = set(messages_qs.values_list("thread_id", flat=True)) + # Count before deletion: delete()'s return value also folds in cascaded + # rows (recipients, attachments), not just the messages themselves. + deleted_count = messages_qs.count() + messages_qs.delete() + + for thread in models.Thread.objects.filter(pk__in=affected_thread_ids): + if thread.messages.exists(): + thread.update_stats() + else: + thread.delete() + + return deleted_count + + +def empty_trashbin(mailbox, scope, user, thread_ids=None, message_ids=None): + """Permanently delete a mailbox's trashbin messages for one folder. + + ``scope`` is ``"trashed"`` or ``"spam"``. ``thread_ids`` / ``message_ids`` + narrow the deletion to specific items; both empty (the default) empties the + whole folder. When both are given they intersect, matching + ``ThreadViewSet.bulk_delete``. + + Deleting a hand-picked message and wiping the folder are the same + privilege — an irreversible hard delete of trashbin content — so both run + through here, behind the single ``CAN_EMPTY_TRASH`` gate the caller + enforces. Splitting per-message deletion onto ``bulk_delete`` instead would + have put it outside that gate entirely (that action has no mailbox context + and no ability check), letting a ``TRASHBIN_ALLOW_EMPTY=never`` deployment + be emptied one selection at a time. + + Only threads ``user`` can fully edit through this mailbox (EDITOR + thread-access + CAN_EDIT mailbox role) are touched, so this can never + hard-delete a shared thread the mailbox merely views. Who may do it at all + is the separate gate above: ``settings.TRASHBIN_ALLOW_EMPTY`` / the mailbox + ability, enforced by the caller. + + Returns the number of messages deleted. + """ + accessible_thread_ids = models.ThreadAccess.objects.editable_by( + user, mailbox_id=mailbox.id + ).values_list("thread_id", flat=True) + messages_qs = models.Message.objects.filter( + TRASHBIN_SCOPE_FILTERS[scope], + thread_id__in=accessible_thread_ids, + ) + if thread_ids: + messages_qs = messages_qs.filter(thread_id__in=thread_ids) + if message_ids: + messages_qs = messages_qs.filter(id__in=message_ids) + + deleted_count = permanently_delete_messages(messages_qs) + # Freeing space is the one storage change a user actively watches, so drop + # the cached usage now instead of waiting out the TTL — the gauge reflects + # the emptied trashbin immediately on the next read. + invalidate_mailbox_storage(mailbox) + return deleted_count + + +# Trashbin items are aged by when they *entered the bin* (``trashed_at``, which +# the trash and spam flag paths both set), falling back to ``created_at`` for +# rows written before that convention or by the importer — so nothing is ever +# permanently exempt from the sweep, and a freshly-binned old message still gets +# its full grace period. +_BINNED_AT = Coalesce("trashed_at", "created_at") + + +@celery_app.task +def cleanup_trashbin_task(batch_size=1000): + """Permanently delete trashbin items (is_trashed OR is_spam) whose bin + entry is older than TRASHBIN_CUTOFF_DAYS. + + ``TRASHBIN_CUTOFF_DAYS = 0`` disables the sweep (see the setting): items are + then kept until someone empties the folder by hand. + + Deletes in bounded batches: a global sweep must not hold one table-wide + transaction or fire an unbounded per-thread ``update_stats`` reindex burst. + Each batch is its own transaction (via ``permanently_delete_messages``); the + filter re-runs each pass, so freshly deleted rows drop out naturally. + """ + cutoff_days = settings.TRASHBIN_CUTOFF_DAYS + if not cutoff_days: + logger.info("cleanup_trashbin_task disabled (TRASHBIN_CUTOFF_DAYS=0)") + return {"deleted_count": 0} + + cutoff = timezone.now() - timedelta(days=cutoff_days) + base_qs = ( + models.Message.objects.filter(Q(is_trashed=True) | Q(is_spam=True)) + .annotate(binned_at=_BINNED_AT) + .filter(binned_at__lt=cutoff) + # Drop Message.Meta.ordering ("-created_at"): the sweep does not care in + # what order it deletes, and keeping it would make every batch sort the + # entire matching set just to take the first ``batch_size`` rows. + .order_by() + ) + + deleted_count = 0 + # Mailboxes whose usage changed, so their cached storage figure can be + # dropped at the end rather than waiting out STORAGE_USAGE_CACHE_TTL — the + # sweep is the single largest space-freeing event there is. + touched_mailbox_ids = set() + 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) + + logger.info( + "cleanup_trashbin_task deleted %d messages older than %d days " + "across %d mailboxes", + deleted_count, + cutoff_days, + len(touched_mailbox_ids), + ) + return {"deleted_count": deleted_count} diff --git a/src/backend/core/tasks.py b/src/backend/core/tasks.py index f99a7097..3fd570fc 100644 --- a/src/backend/core/tasks.py +++ b/src/backend/core/tasks.py @@ -13,3 +13,4 @@ from core.services.importer.tasks import * # noqa: F403 from core.services.push.tasks import * # noqa: F403 from core.services.search.tasks import * # noqa: F403 from core.services.tiered_storage_tasks import * # noqa: F403 +from core.services.trashbin import * # noqa: F403 diff --git a/src/backend/core/tests/api/test_config.py b/src/backend/core/tests/api/test_config.py index 3021a3f9..011915d8 100644 --- a/src/backend/core/tests/api/test_config.py +++ b/src/backend/core/tests/api/test_config.py @@ -36,6 +36,7 @@ pytestmark = pytest.mark.django_db IMAGE_PROXY_ENABLED=False, MESSAGE_TRUSTED_LINK_DOMAINS=[], MESSAGES_MANUAL_RETRY_MAX_AGE=86400, # 1 day in seconds + TRASHBIN_CUTOFF_DAYS=30, FRONTEND_SILENT_LOGIN_ENABLED=True, RELEASE="1.2.3", PUSH_ENABLED=False, @@ -75,6 +76,7 @@ def test_api_config(is_authenticated): "IMAGE_PROXY_ENABLED": False, "MESSAGE_TRUSTED_LINK_DOMAINS": [], "MESSAGES_MANUAL_RETRY_MAX_AGE": 86400, + "TRASHBIN_CUTOFF_DAYS": 30, "FRONTEND_SILENT_LOGIN_ENABLED": True, "PUSH_ENABLED": False, } diff --git a/src/backend/core/tests/api/test_mailbox_empty_trash.py b/src/backend/core/tests/api/test_mailbox_empty_trash.py new file mode 100644 index 00000000..e542c238 --- /dev/null +++ b/src/backend/core/tests/api/test_mailbox_empty_trash.py @@ -0,0 +1,381 @@ +"""Tests for the "empty trashbin" mailbox endpoint. + +The trashbin is the union of trashed and spam messages (``is_trashed OR +is_spam``); this endpoint permanently deletes one folder of it, gated by the +``TRASHBIN_ALLOW_EMPTY`` policy. +""" +# pylint: disable=redefined-outer-name + +from django.core.exceptions import ImproperlyConfigured +from django.urls import reverse + +import pytest +from rest_framework.test import APIClient + +from core import enums, factories, models +from core.enums import TrashbinAllowEmpty + +pytestmark = pytest.mark.django_db + + +def _url(mailbox): + return reverse("mailboxes-empty-trash", kwargs={"pk": str(mailbox.id)}) + + +def _member(mailbox, role): + user = factories.UserFactory() + factories.MailboxAccessFactory(mailbox=mailbox, user=user, role=role) + return user + + +def _message(mailbox, contact, thread_role=None, **flags): + """Create a thread accessible to the mailbox with one flagged message. + + Defaults to EDITOR thread-access so ``editable_by`` (which empty_trashbin + scopes to) includes it; pass ``thread_role=VIEWER`` to exercise the + permission boundary. + """ + if thread_role is None: + thread_role = models.ThreadAccessRoleChoices.EDITOR + thread = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread, role=thread_role) + return factories.MessageFactory( + thread=thread, sender=contact, raw_mime=b"x" * 200, **flags + ) + + +def test_requires_authentication(): + """Anonymous users cannot empty the trashbin.""" + mailbox = factories.MailboxFactory() + response = APIClient().post(_url(mailbox), {"scope": "trashed"}) + assert response.status_code == 401 + + +def test_requires_mailbox_access(): + """A non-member gets 404 (queryset-filtered), not a permission leak.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + client = APIClient() + client.force_login(user) + assert client.post(_url(mailbox), {"scope": "trashed"}).status_code == 404 + + +@pytest.mark.parametrize( + "policy,role,allowed", + [ + # admins policy (default): only ADMIN may empty. + ("admins", models.MailboxRoleChoices.VIEWER, False), + ("admins", models.MailboxRoleChoices.EDITOR, False), + ("admins", models.MailboxRoleChoices.ADMIN, True), + # editors policy: EDITOR and above. + ("editors", models.MailboxRoleChoices.VIEWER, False), + ("editors", models.MailboxRoleChoices.EDITOR, True), + ("editors", models.MailboxRoleChoices.SENDER, True), + ("editors", models.MailboxRoleChoices.ADMIN, True), + # never policy: nobody, not even an admin. + ("never", models.MailboxRoleChoices.ADMIN, False), + ], +) +def test_permission_matrix(settings, policy, role, allowed): + """The TRASHBIN_ALLOW_EMPTY policy gates who may empty, by role.""" + settings.TRASHBIN_ALLOW_EMPTY = policy + mailbox = factories.MailboxFactory() + user = _member(mailbox, role) + + client = APIClient() + client.force_login(user) + response = client.post(_url(mailbox), {"scope": "trashed"}) + + assert response.status_code == (200 if allowed else 403) + + +@pytest.mark.parametrize("policy", ["admin", "Admins", "everyone", "", "true"]) +def test_unknown_policy_raises_instead_of_silently_denying(settings, policy): + """An unrecognised TRASHBIN_ALLOW_EMPTY must fail loudly. + + Matching no branch would otherwise collapse to "nobody may empty" — the + same observable behaviour as a deliberate ``never``, with nothing to say + the deployment is misconfigured. Note "admin" and "Admins": the near-misses + an operator is most likely to write. + """ + settings.TRASHBIN_ALLOW_EMPTY = policy + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + + with pytest.raises(ImproperlyConfigured, match="TRASHBIN_ALLOW_EMPTY"): + mailbox.get_abilities(user) + + +def test_every_enum_member_is_an_accepted_policy(settings): + """The enum is the single source of truth for what get_abilities accepts.""" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + + for policy in TrashbinAllowEmpty: + settings.TRASHBIN_ALLOW_EMPTY = policy + abilities = mailbox.get_abilities(user) + assert abilities[enums.MailboxAbilities.CAN_EMPTY_TRASH] is ( + policy != TrashbinAllowEmpty.NEVER + ) + + +def test_empty_trashed_deletes_only_trashed(settings): + """Emptying scope=trashed removes trashed messages, leaving spam/live.""" + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + + trashed = _message(mailbox, contact, is_trashed=True) + spam = _message(mailbox, contact, is_spam=True) + live = _message(mailbox, contact) + + client = APIClient() + client.force_login(user) + response = client.post(_url(mailbox), {"scope": "trashed"}) + + assert response.status_code == 200 + assert response.json() == {"success": True, "deleted_count": 1} + assert not models.Message.objects.filter(pk=trashed.pk).exists() + assert models.Message.objects.filter(pk=spam.pk).exists() + assert models.Message.objects.filter(pk=live.pk).exists() + + +def test_empty_spam_deletes_only_spam(settings): + """Emptying scope=spam removes spam messages, leaving trashed/live.""" + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + + trashed = _message(mailbox, contact, is_trashed=True) + spam = _message(mailbox, contact, is_spam=True) + + client = APIClient() + client.force_login(user) + response = client.post(_url(mailbox), {"scope": "spam"}) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 1 + assert not models.Message.objects.filter(pk=spam.pk).exists() + assert models.Message.objects.filter(pk=trashed.pk).exists() + + +# --- Targeted deletion: same endpoint, same gate, narrower blast radius --- + + +def test_message_ids_deletes_only_those_messages(settings): + """message_ids narrows the deletion; the rest of the folder survives.""" + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + + target = _message(mailbox, contact, is_trashed=True) + other = _message(mailbox, contact, is_trashed=True) + + client = APIClient() + client.force_login(user) + response = client.post( + _url(mailbox), + {"scope": "trashed", "message_ids": [str(target.id)]}, + format="json", + ) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 1 + assert not models.Message.objects.filter(pk=target.pk).exists() + assert models.Message.objects.filter(pk=other.pk).exists() + + +def test_thread_ids_deletes_only_that_thread(settings): + """thread_ids narrows the deletion to one conversation.""" + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + + target = _message(mailbox, contact, is_trashed=True) + other = _message(mailbox, contact, is_trashed=True) + + client = APIClient() + client.force_login(user) + response = client.post( + _url(mailbox), + {"scope": "trashed", "thread_ids": [str(target.thread_id)]}, + format="json", + ) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 1 + assert not models.Message.objects.filter(pk=target.pk).exists() + assert models.Message.objects.filter(pk=other.pk).exists() + + +def test_targeting_still_respects_scope(settings): + """An explicitly targeted message outside the scope is not deleted. + + Targeting must narrow the selection, never widen it past ``scope`` — asking + to delete a live message under scope=trashed is a no-op, not a hard delete. + """ + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + + live = _message(mailbox, contact) + + client = APIClient() + client.force_login(user) + response = client.post( + _url(mailbox), + {"scope": "trashed", "message_ids": [str(live.id)]}, + format="json", + ) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 0 + assert models.Message.objects.filter(pk=live.pk).exists() + + +def test_empty_target_lists_still_empty_the_whole_folder(settings): + """Explicit empty lists mean "everything", matching the omitted default. + + The opposite of bulk-delete, where no target is a 400 — here it is the + primary use case. + """ + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + + _message(mailbox, contact, is_trashed=True) + _message(mailbox, contact, is_trashed=True) + + client = APIClient() + client.force_login(user) + response = client.post( + _url(mailbox), + {"scope": "trashed", "thread_ids": [], "message_ids": []}, + format="json", + ) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 2 + + +@pytest.mark.parametrize( + "payload", + [ + {"scope": "trashed"}, + {"scope": "trashed", "message_ids": "__target__"}, + ], + ids=["whole-folder", "single-message"], +) +def test_single_message_needs_the_same_permission_as_the_folder(settings, payload): + """Deleting one message is gated exactly like emptying the folder. + + This is the whole reason targeted deletion lives on this endpoint: under + TRASHBIN_ALLOW_EMPTY=never neither form is permitted. Routing per-message + deletion through thread bulk-delete instead would have made the second case + succeed, silently defeating the policy. + """ + settings.TRASHBIN_ALLOW_EMPTY = "never" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + target = _message(mailbox, contact, is_trashed=True) + + if payload.get("message_ids") == "__target__": + payload = {**payload, "message_ids": [str(target.id)]} + + client = APIClient() + client.force_login(user) + response = client.post(_url(mailbox), payload, format="json") + + assert response.status_code == 403 + assert models.Message.objects.filter(pk=target.pk).exists() + + +def test_targeted_delete_cannot_reach_another_mailbox(settings): + """Naming a message in someone else's mailbox deletes nothing. + + The accessible-thread scoping still applies on top of the explicit target. + """ + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + + other_mailbox = factories.MailboxFactory() + other_contact = factories.ContactFactory(mailbox=other_mailbox) + victim = _message(other_mailbox, other_contact, is_trashed=True) + + client = APIClient() + client.force_login(user) + response = client.post( + _url(mailbox), + {"scope": "trashed", "message_ids": [str(victim.id)]}, + format="json", + ) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 0 + assert models.Message.objects.filter(pk=victim.pk).exists() + + +def test_invalid_scope_rejected(settings): + """An unknown scope is a 400, not a silent no-op.""" + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + + client = APIClient() + client.force_login(user) + response = client.post(_url(mailbox), {"scope": "draft"}) + assert response.status_code == 400 + + +def test_viewer_on_thread_cannot_hard_delete(settings): + """A thread the mailbox only VIEWs is not emptied, even by a mailbox admin. + + Matches bulk_delete: hard-deleting shared-thread messages requires EDITOR + thread-access, not just the mailbox-level empty_trash ability. + """ + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + contact = factories.ContactFactory(mailbox=mailbox) + + viewer_msg = _message( + mailbox, + contact, + thread_role=models.ThreadAccessRoleChoices.VIEWER, + is_trashed=True, + ) + + client = APIClient() + client.force_login(user) + response = client.post(_url(mailbox), {"scope": "trashed"}) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 0 + assert models.Message.objects.filter(pk=viewer_msg.pk).exists() + + +def test_only_targets_own_mailbox(settings): + """A message in another mailbox's threads is untouched.""" + settings.TRASHBIN_ALLOW_EMPTY = "admins" + mailbox = factories.MailboxFactory() + user = _member(mailbox, models.MailboxRoleChoices.ADMIN) + + other_mailbox = factories.MailboxFactory() + other_contact = factories.ContactFactory(mailbox=other_mailbox) + other_trashed = _message(other_mailbox, other_contact, is_trashed=True) + + client = APIClient() + client.force_login(user) + response = client.post(_url(mailbox), {"scope": "trashed"}) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 0 + assert models.Message.objects.filter(pk=other_trashed.pk).exists() diff --git a/src/backend/core/tests/api/test_mailbox_entitlements.py b/src/backend/core/tests/api/test_mailbox_entitlements.py new file mode 100644 index 00000000..1f4fc192 --- /dev/null +++ b/src/backend/core/tests/api/test_mailbox_entitlements.py @@ -0,0 +1,154 @@ +"""Tests for the per-mailbox entitlements (storage quota) endpoint.""" +# pylint: disable=redefined-outer-name + +from django.test import override_settings +from django.urls import reverse + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories, models +from core.entitlements import EntitlementsUnavailableError +from core.entitlements.factory import get_entitlements_backend + +pytestmark = pytest.mark.django_db + + +LOCAL_BACKEND = "core.entitlements.backends.local.LocalEntitlementsBackend" + + +@pytest.fixture(autouse=True) +def _reset_entitlements_backend(): + """The configured backend is a functools-cached singleton; drop it so each + test's ``override_settings`` picks up fresh backend parameters.""" + get_entitlements_backend.cache_clear() + yield + get_entitlements_backend.cache_clear() + + +@pytest.fixture +def user(): + """A regular user.""" + return factories.UserFactory() + + +@pytest.fixture +def mailbox(): + """A mailbox.""" + return factories.MailboxFactory() + + +def url(mailbox): + """Return the entitlements action URL for a mailbox.""" + return reverse("mailboxes-entitlements", kwargs={"pk": mailbox.id}) + + +def test_requires_authentication(mailbox): + """Anonymous users cannot read entitlements.""" + response = APIClient().get(url(mailbox)) + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + +def test_forbidden_without_mailbox_access(user, mailbox): + """A user without access to the mailbox gets a 404 (queryset-filtered).""" + client = APIClient() + client.force_authenticate(user=user) + response = client.get(url(mailbox)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"mailbox_storage_limit": 1000}, +) +def test_returns_account_storage(user, mailbox): + """A member sees usage and the configured limit for the mailbox.""" + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.VIEWER + ) + client = APIClient() + client.force_authenticate(user=user) + + response = client.get(url(mailbox)) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["account"]["max_storage"] == 1000 + assert data["account"]["storage_used"] == 0 + # No organization custom attribute set on the domain -> no org level. + assert data["organization"] is None + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={"mailbox_storage_limit": 1000}, +) +def test_storage_used_counts_messages(user, mailbox): + """storage_used reflects message overhead for the mailbox's threads.""" + overhead = 1024 + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.VIEWER + ) + contact = factories.ContactFactory(mailbox=mailbox) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread) + factories.MessageFactory(thread=thread, sender=contact) + factories.MessageFactory(thread=thread, sender=contact) + + client = APIClient() + client.force_authenticate(user=user) + + with override_settings(METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE=overhead): + response = client.get(url(mailbox)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["account"]["storage_used"] == 2 * overhead + + +@override_settings( + ENTITLEMENTS_BACKEND=LOCAL_BACKEND, + ENTITLEMENTS_BACKEND_PARAMETERS={ + "mailbox_storage_limit": 1000, + "organization_storage_limit": 5000, + "organization_claim": "siret", + }, +) +def test_returns_organization_level_when_domain_has_org(user): + """When the domain carries the org claim, an organization level is returned.""" + domain = factories.MailDomainFactory(custom_attributes={"siret": "12345678900001"}) + mailbox = factories.MailboxFactory(domain=domain) + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.ADMIN + ) + client = APIClient() + client.force_authenticate(user=user) + + response = client.get(url(mailbox)) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["organization"] == {"storage_used": 0, "max_storage": 5000} + + +@override_settings(ENTITLEMENTS_BACKEND=LOCAL_BACKEND) +def test_degrades_when_backend_unavailable(user, mailbox, monkeypatch): + """A backend outage hides the gauge (null limit) rather than erroring.""" + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.VIEWER + ) + + def _raise(*_args, **_kwargs): + raise EntitlementsUnavailableError("down") + + monkeypatch.setattr("core.api.viewsets.mailbox.get_mailbox_entitlements", _raise) + + client = APIClient() + client.force_authenticate(user=user) + response = client.get(url(mailbox)) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data == { + "account": {"storage_used": 0, "max_storage": None}, + "organization": None, + } diff --git a/src/backend/core/tests/api/test_mailbox_storage.py b/src/backend/core/tests/api/test_mailbox_storage.py new file mode 100644 index 00000000..35e71eeb --- /dev/null +++ b/src/backend/core/tests/api/test_mailbox_storage.py @@ -0,0 +1,207 @@ +"""Tests for the mailbox storage endpoint (Storage settings tab).""" +# pylint: disable=redefined-outer-name + +from django.urls import reverse +from django.utils import timezone + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + + +def _url(mailbox): + return reverse("mailboxes-storage", kwargs={"pk": str(mailbox.id)}) + + +def test_requires_authentication(): + """Anonymous users cannot read storage stats.""" + mailbox = factories.MailboxFactory() + response = APIClient().get(_url(mailbox)) + assert response.status_code == 401 + + +def test_requires_mailbox_access(): + """A user without access to the mailbox gets a 404 (queryset-filtered).""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + + client = APIClient() + client.force_login(user) + assert client.get(_url(mailbox)).status_code == 404 + + +def test_non_admin_member_forbidden(): + """A member without admin rights cannot read the storage breakdown.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.VIEWER + ) + + client = APIClient() + client.force_login(user) + assert client.get(_url(mailbox)).status_code == 403 + + +def test_empty_mailbox_returns_zeroes(): + """A mailbox with no threads reports zero storage and an empty list.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.ADMIN + ) + + client = APIClient() + client.force_login(user) + response = client.get(_url(mailbox)) + + assert response.status_code == 200 + assert response.json() == { + "total_storage": 0, + "trashed_storage": 0, + "spam_storage": 0, + "message_count": 0, + "thread_count": 0, + "largest_threads": [], + } + + +def test_storage_formula_and_largest_threads(settings): + """Total follows the metrics formula; threads are ranked by size.""" + overhead = settings.METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE + + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.ADMIN + ) + contact = factories.ContactFactory(mailbox=mailbox) + + thread_small = factories.ThreadFactory(subject="small") + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread_small) + msg_small = factories.MessageFactory( + thread=thread_small, sender=contact, raw_mime=b"s" * 100 + ) + + thread_big = factories.ThreadFactory(subject="big", messaged_at=timezone.now()) + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread_big) + # Distinct content so the blobs are not deduplicated by sha256. + msg_big1 = factories.MessageFactory( + thread=thread_big, sender=contact, raw_mime=b"b" * 5000 + ) + msg_big2 = factories.MessageFactory( + thread=thread_big, sender=contact, raw_mime=b"c" * 5000 + ) + att = factories.AttachmentFactory(mailbox=mailbox, blob_size=500, message=msg_big1) + + client = APIClient() + client.force_login(user) + response = client.get(_url(mailbox)) + + assert response.status_code == 200 + data = response.json() + + expected_total = ( + 3 * overhead + + msg_small.blob.size_compressed + + msg_big1.blob.size_compressed + + msg_big2.blob.size_compressed + + att.blob.size_compressed + ) + assert data["total_storage"] == expected_total + assert data["message_count"] == 3 + assert data["thread_count"] == 2 + + # Ranked by (mime + draft) blob size descending: big thread first. + assert [t["subject"] for t in data["largest_threads"]] == ["big", "small"] + big = data["largest_threads"][0] + assert big["message_count"] == 2 + assert big["size"] == ( + msg_big1.blob.size_compressed + msg_big2.blob.size_compressed + ) + assert big["messaged_at"] is not None + + +def test_trashed_and_spam_storage(settings): + """trashed_storage and spam_storage each sum only their own messages.""" + overhead = settings.METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE + + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.ADMIN + ) + contact = factories.ContactFactory(mailbox=mailbox) + + live_thread = factories.ThreadFactory(subject="live") + factories.ThreadAccessFactory(mailbox=mailbox, thread=live_thread) + factories.MessageFactory(thread=live_thread, sender=contact, raw_mime=b"live" * 100) + + trashed_thread = factories.ThreadFactory(subject="trashed", is_trashed=True) + factories.ThreadAccessFactory(mailbox=mailbox, thread=trashed_thread) + trashed_msg = factories.MessageFactory( + thread=trashed_thread, sender=contact, raw_mime=b"trash" * 100, is_trashed=True + ) + + spam_thread = factories.ThreadFactory(subject="spam", is_spam=True) + factories.ThreadAccessFactory(mailbox=mailbox, thread=spam_thread) + spam_msg = factories.MessageFactory( + thread=spam_thread, sender=contact, raw_mime=b"spam" * 100, is_spam=True + ) + + client = APIClient() + client.force_login(user) + response = client.get(_url(mailbox)) + + assert response.status_code == 200 + data = response.json() + + assert data["trashed_storage"] == 1 * overhead + trashed_msg.blob.size_compressed + assert data["spam_storage"] == 1 * overhead + spam_msg.blob.size_compressed + assert data["trashed_storage"] < data["total_storage"] + assert data["spam_storage"] < data["total_storage"] + + +def test_trashed_storage_is_scoped_per_message_not_per_thread(settings): + """Only the trashed messages of a partly-trashed thread are counted. + + Thread.is_trashed is true only when *every* message is trashed, so scoping + by the thread flag would report 0 here; Thread.is_spam mirrors the first + message alone, so it would report the whole thread as spam. Both are + denormalizations for the folder filters, not storage accounting. + """ + overhead = settings.METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE + + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.ADMIN + ) + contact = factories.ContactFactory(mailbox=mailbox) + + # One thread, one trashed message and one live one: the thread's own + # is_trashed stays False because not all of its messages are trashed. + thread = factories.ThreadFactory(subject="mixed") + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread) + trashed_msg = factories.MessageFactory( + thread=thread, sender=contact, raw_mime=b"trash" * 100, is_trashed=True + ) + factories.MessageFactory(thread=thread, sender=contact, raw_mime=b"live" * 100) + + thread.refresh_from_db() + thread.update_stats() + thread.refresh_from_db() + assert thread.is_trashed is False + + client = APIClient() + client.force_login(user) + response = client.get(_url(mailbox)) + + assert response.status_code == 200 + data = response.json() + + assert data["trashed_storage"] == 1 * overhead + trashed_msg.blob.size_compressed + assert data["spam_storage"] == 0 diff --git a/src/backend/core/tests/api/test_messages_flag.py b/src/backend/core/tests/api/test_messages_flag.py index 0528862c..4b5a9b17 100644 --- a/src/backend/core/tests/api/test_messages_flag.py +++ b/src/backend/core/tests/api/test_messages_flag.py @@ -789,6 +789,130 @@ def test_api_flag_mark_messages_untrashed_success(api_client): assert thread.has_trashed is False +# --- trashed_at belongs to the trashbin pair (is_trashed OR is_spam) --- +# +# The cutoff sweep ages an item by trashed_at, falling back to created_at when +# it is NULL. Clearing one half of the pair while the other is still set would +# leave the message in the bin with a NULL timestamp, so old mail would be +# permanently deleted on the next nightly run instead of getting its grace +# period. See core.services.trashbin and viewsets/flag.trashbin_timestamp. + + +def test_api_flag_unspam_keeps_trashed_at_while_still_trashed(api_client): + """Un-spamming a message that is also trashed must keep trashed_at set.""" + user = UserFactory() + api_client.force_authenticate(user=user) + mailbox = MailboxFactory(users_admin=[user]) + thread = ThreadFactory() + ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + binned_at = timezone.now() - timedelta(days=3) + msg = MessageFactory( + thread=thread, is_trashed=True, is_spam=True, trashed_at=binned_at + ) + + data = {"flag": "spam", "value": False, "message_ids": [str(msg.id)]} + response = api_client.post(API_URL, data=data, format="json") + assert response.status_code == status.HTTP_200_OK + + msg.refresh_from_db() + assert msg.is_spam is False + assert msg.is_trashed is True + # Still in the bin, so the original entry time survives untouched. + assert msg.trashed_at == binned_at + + +def test_api_flag_untrash_keeps_trashed_at_while_still_spam(api_client): + """Un-trashing a message that is also spam must keep trashed_at set.""" + user = UserFactory() + api_client.force_authenticate(user=user) + mailbox = MailboxFactory(users_admin=[user]) + thread = ThreadFactory() + ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + binned_at = timezone.now() - timedelta(days=3) + msg = MessageFactory( + thread=thread, is_trashed=True, is_spam=True, trashed_at=binned_at + ) + + data = {"flag": "trashed", "value": False, "message_ids": [str(msg.id)]} + response = api_client.post(API_URL, data=data, format="json") + assert response.status_code == status.HTTP_200_OK + + msg.refresh_from_db() + assert msg.is_trashed is False + assert msg.is_spam is True + assert msg.trashed_at == binned_at + + +def test_api_flag_leaving_the_bin_entirely_clears_trashed_at(api_client): + """Clearing the last remaining trashbin flag does clear trashed_at.""" + user = UserFactory() + api_client.force_authenticate(user=user) + mailbox = MailboxFactory(users_admin=[user]) + thread = ThreadFactory() + ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + msg = MessageFactory(thread=thread, is_spam=True, trashed_at=timezone.now()) + + data = {"flag": "spam", "value": False, "message_ids": [str(msg.id)]} + response = api_client.post(API_URL, data=data, format="json") + assert response.status_code == status.HTTP_200_OK + + msg.refresh_from_db() + assert msg.is_spam is False + assert msg.trashed_at is None + + +def test_api_flag_reflagging_does_not_restart_the_retention_clock(api_client): + """Flagging the other half of the pair keeps the original bin entry time. + + Otherwise toggling spam on an already-trashed message would postpone its + permanent deletion indefinitely. + """ + user = UserFactory() + api_client.force_authenticate(user=user) + mailbox = MailboxFactory(users_admin=[user]) + thread = ThreadFactory() + ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + binned_at = timezone.now() - timedelta(days=20) + msg = MessageFactory(thread=thread, is_trashed=True, trashed_at=binned_at) + + data = {"flag": "spam", "value": True, "message_ids": [str(msg.id)]} + response = api_client.post(API_URL, data=data, format="json") + assert response.status_code == status.HTTP_200_OK + + msg.refresh_from_db() + assert msg.is_spam is True + assert msg.trashed_at == binned_at + + +def test_api_flag_entering_the_bin_stamps_trashed_at(api_client): + """A message with no timestamp gets one when it first enters the bin.""" + user = UserFactory() + api_client.force_authenticate(user=user) + mailbox = MailboxFactory(users_admin=[user]) + thread = ThreadFactory() + ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + msg = MessageFactory(thread=thread) + assert msg.trashed_at is None + + data = {"flag": "spam", "value": True, "message_ids": [str(msg.id)]} + response = api_client.post(API_URL, data=data, format="json") + assert response.status_code == status.HTTP_200_OK + + msg.refresh_from_db() + assert msg.is_spam is True + assert msg.trashed_at is not None + + # --- Tests for Archived Flag --- diff --git a/src/backend/core/tests/entitlements/test_mailbox_backends.py b/src/backend/core/tests/entitlements/test_mailbox_backends.py new file mode 100644 index 00000000..cc2fca41 --- /dev/null +++ b/src/backend/core/tests/entitlements/test_mailbox_backends.py @@ -0,0 +1,188 @@ +"""Unit tests for the per-mailbox storage entitlements of each backend.""" +# pylint: disable=redefined-outer-name + +import json + +from django.core.cache import cache +from django.test import override_settings + +import pytest +import responses + +from core import factories +from core.entitlements import EntitlementsUnavailableError +from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend +from core.entitlements.backends.local import LocalEntitlementsBackend + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_cache(): + cache.clear() + yield + cache.clear() + + +class TestLocalMailboxEntitlements: + """Tests for LocalEntitlementsBackend.get_mailbox_entitlements.""" + + def test_account_limit_and_usage(self): + """Returns the configured limit and DB-computed usage; no org level.""" + mailbox = factories.MailboxFactory() + backend = LocalEntitlementsBackend(mailbox_storage_limit=1000) + + result = backend.get_mailbox_entitlements(mailbox) + assert result == { + "account": {"storage_used": 0, "max_storage": 1000}, + "organization": None, + } + + def test_unlimited_when_limit_none(self): + """A None mailbox limit surfaces as max_storage=None (gauge hidden).""" + mailbox = factories.MailboxFactory() + backend = LocalEntitlementsBackend(mailbox_storage_limit=None) + + result = backend.get_mailbox_entitlements(mailbox) + assert result["account"]["max_storage"] is None + + def test_organization_level_when_domain_has_claim(self): + """When the domain carries the org claim, an organization level appears.""" + domain = factories.MailDomainFactory( + custom_attributes={"siret": "12345678900001"} + ) + mailbox = factories.MailboxFactory(domain=domain) + backend = LocalEntitlementsBackend( + mailbox_storage_limit=1000, organization_storage_limit=5000 + ) + + result = backend.get_mailbox_entitlements(mailbox) + assert result["organization"] == {"storage_used": 0, "max_storage": 5000} + + def test_storage_used_counts_message_overhead(self): + """storage_used aggregates message overhead across the mailbox threads.""" + overhead = 1024 + mailbox = factories.MailboxFactory() + contact = factories.ContactFactory(mailbox=mailbox) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread) + factories.MessageFactory(thread=thread, sender=contact) + + backend = LocalEntitlementsBackend(mailbox_storage_limit=10_000) + with override_settings(METRICS_STORAGE_USED_OVERHEAD_BY_MESSAGE=overhead): + result = backend.get_mailbox_entitlements(mailbox) + assert result["account"]["storage_used"] == overhead + + +BASE_URL = "https://deploycenter.example.com/api/v1.0/entitlements" + + +class TestDeployCenterMailboxEntitlements: + """Tests for DeployCenterEntitlementsBackend.get_mailbox_entitlements.""" + + def _get_backend(self, **kwargs): + defaults = { + "base_url": BASE_URL, + "service_id": "test-service", + "api_key": "test-api-key", + "timeout": 5, + } + defaults.update(kwargs) + return DeployCenterEntitlementsBackend(**defaults) + + @responses.activate + def test_posts_usage_metrics_and_parses_limits(self): + """POSTs usage_metrics for mailbox + org and reads back the limits.""" + responses.add( + responses.POST, + BASE_URL, + json={ + "entitlements": { + "max_storage_account": 1000, + "max_storage_organization": 5000, + }, + "metrics": { + "account": {"storage_used": 42}, + "organization": {"storage_used": 99}, + }, + }, + status=200, + ) + + domain = factories.MailDomainFactory( + name="example.com", custom_attributes={"siret": "12345678900001"} + ) + mailbox = factories.MailboxFactory(domain=domain, local_part="alice") + + result = self._get_backend().get_mailbox_entitlements(mailbox) + + assert result == { + "account": {"storage_used": 42, "max_storage": 1000}, + "organization": {"storage_used": 99, "max_storage": 5000}, + } + + # A single POST carrying the usage metrics and the mailbox scope. + assert len(responses.calls) == 1 + request = responses.calls[0].request + assert responses.calls[0].request.method == "POST" + assert "account_type=mailbox" in request.url + assert "account_email=alice%40example.com" in request.url + assert "siret=12345678900001" in request.url + assert request.headers["X-Service-Auth"] == "Bearer test-api-key" + + body = json.loads(request.body) + types = {item["account"]["type"] for item in body["usage_metrics"]} + assert types == {"mailbox", "organization"} + + @responses.activate + def test_no_organization_when_domain_has_no_claim(self): + """Without an org claim on the domain, only the mailbox scope is pushed.""" + responses.add( + responses.POST, + BASE_URL, + json={ + "entitlements": {"max_storage_account": 1000}, + "metrics": {"account": {"storage_used": 10}}, + }, + status=200, + ) + + mailbox = factories.MailboxFactory() + result = self._get_backend().get_mailbox_entitlements(mailbox) + + assert result["organization"] is None + body = json.loads(responses.calls[0].request.body) + assert [item["account"]["type"] for item in body["usage_metrics"]] == [ + "mailbox" + ] + + @responses.activate + @override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300) + def test_cache_hit_avoids_second_post(self): + """A cached mailbox result is reused without a second POST.""" + responses.add( + responses.POST, + BASE_URL, + json={ + "entitlements": {"max_storage_account": 1000}, + "metrics": {"account": {"storage_used": 10}}, + }, + status=200, + ) + mailbox = factories.MailboxFactory() + backend = self._get_backend() + + result1 = backend.get_mailbox_entitlements(mailbox) + result2 = backend.get_mailbox_entitlements(mailbox) + + assert result1 == result2 + assert len(responses.calls) == 1 + + @responses.activate + def test_failure_without_cache_raises(self): + """A server error with no cache raises EntitlementsUnavailableError.""" + responses.add(responses.POST, BASE_URL, status=500) + mailbox = factories.MailboxFactory() + + with pytest.raises(EntitlementsUnavailableError): + self._get_backend().get_mailbox_entitlements(mailbox) diff --git a/src/backend/core/tests/importer/test_import_channel.py b/src/backend/core/tests/importer/test_import_channel.py index 7d9903ae..33829b34 100644 --- a/src/backend/core/tests/importer/test_import_channel.py +++ b/src/backend/core/tests/importer/test_import_channel.py @@ -8,6 +8,10 @@ resume idempotent for header-less mail, and cancellation. # pylint: disable=redefined-outer-name, unused-argument +from datetime import timedelta + +from django.utils import timezone + import pytest from jmap_email import parse_email @@ -295,6 +299,67 @@ class TestLabelsAndDuplicateMerge: assert access.read_at is not None +@pytest.mark.django_db +class TestImportTrashbinTimestamps: + """Imported Trash/Spam is dated from the import, not from the mail itself. + + The importer carries these two flags through IMAP labels (``message_flags``) + rather than the ``is_trashed``/``is_spam`` arguments, and it backdates + ``created_at`` to the message's own Date header. Without an explicit + ``trashed_at`` the cutoff sweep would age an imported bin item by that + original date, so importing a years-old Spam folder would hand the whole + thing to the first nightly run. See ``core.services.trashbin``. + """ + + @pytest.mark.parametrize( + "label,field", [("Spam", "is_spam"), ("Trash", "is_trashed")] + ) + def test_imported_bin_item_is_stamped_at_import_time( + self, mailbox, user, label, field + ): + channel = create_import_channel( + recipient=mailbox, user=user, source_type=enums.ImportSource.IMAP.value + ) + raw = _raw(mailbox, message_id=f"<{label.lower()}@example.com>") + assert deliver_inbound_message( + str(mailbox), + parse_email(raw), + raw, + is_import=True, + channel=channel, + imap_labels=[label], + ) + + message = models.Message.objects.get(channel=channel) + assert getattr(message, field) is True + # created_at is backdated to the Date header (May 2025 in _raw)... + assert message.created_at < timezone.now() - timedelta(days=30) + # ...but the message only entered the bin now, so it gets the full + # TRASHBIN_CUTOFF_DAYS grace rather than being swept immediately. + assert message.trashed_at is not None + assert message.trashed_at > timezone.now() - timedelta(minutes=5) + + def test_imported_live_message_has_no_trashed_at(self, mailbox, user): + """Only bin items are stamped; ordinary imported mail stays NULL.""" + channel = create_import_channel( + recipient=mailbox, user=user, source_type=enums.ImportSource.IMAP.value + ) + raw = _raw(mailbox, message_id="") + assert deliver_inbound_message( + str(mailbox), + parse_email(raw), + raw, + is_import=True, + channel=channel, + imap_labels=["ProjectX"], + ) + + message = models.Message.objects.get(channel=channel) + assert message.is_trashed is False + assert message.is_spam is False + assert message.trashed_at is None + + @pytest.mark.django_db class TestCancel: def test_cancel_deletes_messages_and_disables(self, mailbox, user): diff --git a/src/backend/core/tests/services/test_storage_cache.py b/src/backend/core/tests/services/test_storage_cache.py new file mode 100644 index 00000000..b2c0e470 --- /dev/null +++ b/src/backend/core/tests/services/test_storage_cache.py @@ -0,0 +1,89 @@ +"""Tests for the service-layer storage-usage cache.""" + +from django.core.cache import cache + +import pytest + +from core import factories, models +from core.services.storage import ( + compute_mailbox_storage_used, + get_mailbox_storage_used, + invalidate_mailbox_storage, +) +from core.services.trashbin import empty_trashbin + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_cache(): + cache.clear() + yield + cache.clear() + + +def _mailbox_with_message(**flags): + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=models.MailboxRoleChoices.ADMIN + ) + contact = factories.ContactFactory(mailbox=mailbox) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=models.ThreadAccessRoleChoices.EDITOR + ) + factories.MessageFactory( + thread=thread, sender=contact, raw_mime=b"x" * 500, **flags + ) + return user, mailbox, contact + + +def test_get_caches_until_invalidated(settings): + """The cached getter holds a value until explicitly invalidated.""" + settings.STORAGE_USAGE_CACHE_TTL = 60 + user, mailbox, contact = _mailbox_with_message() + + first = get_mailbox_storage_used(mailbox) + assert first == compute_mailbox_storage_used(mailbox) + + # A newly added message changes the true value, but the cache still holds. + thread = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread) + factories.MessageFactory(thread=thread, sender=contact, raw_mime=b"y" * 5000) + live = compute_mailbox_storage_used(mailbox) + assert live > first + assert get_mailbox_storage_used(mailbox) == first # still cached, stale + + invalidate_mailbox_storage(mailbox) + assert get_mailbox_storage_used(mailbox) == live # recomputed + + +def test_ttl_zero_disables_cache(settings): + """TTL=0 bypasses the cache and always recomputes.""" + settings.STORAGE_USAGE_CACHE_TTL = 0 + user, mailbox, contact = _mailbox_with_message() + + first = get_mailbox_storage_used(mailbox) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread) + factories.MessageFactory(thread=thread, sender=contact, raw_mime=b"z" * 5000) + + assert get_mailbox_storage_used(mailbox) > first # no caching + + +def test_empty_trashbin_invalidates_cache(settings): + """Emptying the trashbin drops the cached usage so the gauge updates.""" + settings.STORAGE_USAGE_CACHE_TTL = 60 + settings.TRASHBIN_ALLOW_EMPTY = "admins" + user, mailbox, contact = _mailbox_with_message(is_trashed=True) + + before = get_mailbox_storage_used(mailbox) # primes the cache + assert before > 0 + + deleted = empty_trashbin(mailbox, "trashed", user) + assert deleted == 1 + + # Without invalidation this would still read `before`; the emptied trashbin + # is reflected immediately. + assert get_mailbox_storage_used(mailbox) < before diff --git a/src/backend/core/tests/tasks/test_cleanup_trashbin.py b/src/backend/core/tests/tasks/test_cleanup_trashbin.py new file mode 100644 index 00000000..172eea3e --- /dev/null +++ b/src/backend/core/tests/tasks/test_cleanup_trashbin.py @@ -0,0 +1,125 @@ +"""Tests for the trashbin cutoff sweep (``cleanup_trashbin_task``). + +The task permanently deletes trashbin items (``is_trashed OR is_spam``) older +than ``TRASHBIN_CUTOFF_DAYS``. Trashed items are aged by ``trashed_at``, spam by +``created_at`` (its receipt time). +""" + +from datetime import timedelta + +from django.utils import timezone + +import pytest + +from core import factories, models +from core.services.trashbin import cleanup_trashbin_task + +pytestmark = pytest.mark.django_db + + +def _message(**flags): + """A message on its own thread, with the given flags.""" + return factories.MessageFactory(raw_mime=b"x" * 200, **flags) + + +def _age_created_at(message, when): + """Force created_at (auto_now_add) into the past via a raw UPDATE.""" + models.Message.objects.filter(pk=message.pk).update(created_at=when) + + +def test_deletes_old_trashed_and_spam(settings): + """Items past the cutoff are deleted; fresh ones are kept.""" + settings.TRASHBIN_CUTOFF_DAYS = 30 + now = timezone.now() + old = now - timedelta(days=31) + recent = now - timedelta(days=5) + + old_trashed = _message(is_trashed=True, trashed_at=old) + old_spam = _message(is_spam=True) + _age_created_at(old_spam, old) + + fresh_trashed = _message(is_trashed=True, trashed_at=recent) + fresh_spam = _message(is_spam=True) # created_at defaults to ~now + live = _message() + + result = cleanup_trashbin_task() + + assert result == {"deleted_count": 2} + assert not models.Message.objects.filter(pk=old_trashed.pk).exists() + assert not models.Message.objects.filter(pk=old_spam.pk).exists() + assert models.Message.objects.filter(pk=fresh_trashed.pk).exists() + assert models.Message.objects.filter(pk=fresh_spam.pk).exists() + assert models.Message.objects.filter(pk=live.pk).exists() + + +def test_recently_binned_old_message_gets_grace_period(settings): + """An old message binned recently is aged by trashed_at, not created_at. + + Marking a 60-day-old message as spam/trash today must give it the full + cutoff window, not delete it on the next run. + """ + settings.TRASHBIN_CUTOFF_DAYS = 30 + now = timezone.now() + old = now - timedelta(days=60) + recent = now - timedelta(days=1) + + # Old message, only just moved to the bin (trashed_at recent). + trashed = _message(is_trashed=True, trashed_at=recent) + _age_created_at(trashed, old) + spam = _message(is_spam=True, trashed_at=recent) + _age_created_at(spam, old) + + result = cleanup_trashbin_task() + + assert result == {"deleted_count": 0} + assert models.Message.objects.filter(pk=trashed.pk).exists() + assert models.Message.objects.filter(pk=spam.pk).exists() + + +def test_ages_by_created_at_when_trashed_at_missing(settings): + """Imported/legacy binned rows (NULL trashed_at) still age by created_at.""" + settings.TRASHBIN_CUTOFF_DAYS = 30 + old = timezone.now() - timedelta(days=40) + + # NULL trashed_at (as the importer writes), old created_at. + imported = _message(is_trashed=True) + _age_created_at(imported, old) + + assert cleanup_trashbin_task() == {"deleted_count": 1} + assert not models.Message.objects.filter(pk=imported.pk).exists() + + +def test_cutoff_zero_disables_the_sweep(settings): + """TRASHBIN_CUTOFF_DAYS=0 turns automatic deletion off, not "delete all".""" + settings.TRASHBIN_CUTOFF_DAYS = 0 + ancient = timezone.now() - timedelta(days=3650) + + trashed = _message(is_trashed=True, trashed_at=ancient) + spam = _message(is_spam=True, trashed_at=ancient) + + assert cleanup_trashbin_task() == {"deleted_count": 0} + assert models.Message.objects.filter(pk=trashed.pk).exists() + assert models.Message.objects.filter(pk=spam.pk).exists() + + +def test_deletes_nothing_when_empty(settings): + """No trashbin items → a no-op returning zero.""" + settings.TRASHBIN_CUTOFF_DAYS = 30 + _message() # a live message + + assert cleanup_trashbin_task() == {"deleted_count": 0} + + +def test_emptied_thread_is_removed(settings): + """A thread whose only message is swept away is deleted with it.""" + settings.TRASHBIN_CUTOFF_DAYS = 30 + old = timezone.now() - timedelta(days=40) + + thread = factories.ThreadFactory() + factories.MessageFactory( + thread=thread, raw_mime=b"x" * 200, is_trashed=True, trashed_at=old + ) + + cleanup_trashbin_task() + + assert not models.Thread.objects.filter(pk=thread.pk).exists() diff --git a/src/backend/messages/celery_app.py b/src/backend/messages/celery_app.py index eb9c305e..e64637b6 100644 --- a/src/backend/messages/celery_app.py +++ b/src/backend/messages/celery_app.py @@ -40,6 +40,14 @@ if not settings.DISABLE_CELERY_BEAT_SCHEDULE: "schedule": settings.MESSAGES_SELFCHECK_INTERVAL, "options": {"queue": "outbound"}, }, + "cleanup-trashbin": { + # Permanently delete trashbin items (trashed + spam) older than + # TRASHBIN_CUTOFF_DAYS. Daily housekeeping, default queue like the + # other GC sweeps. + "task": "core.services.trashbin.cleanup_trashbin_task", + "schedule": 86400.0, # daily + "options": {"queue": "default"}, + }, "process-inbound-messages-queue": { "task": "core.mda.inbound_tasks.process_inbound_messages_queue_task", "schedule": 300.0, # Every 5 minutes diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 0fdded6e..7214d79f 100644 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -1064,9 +1064,38 @@ class Base(Configuration): "REDOC_DIST": "SIDECAR", } - TRASHBIN_CUTOFF_DAYS = values.Value( + # The "trashbin" is the union of trashed and spam messages + # (``is_trashed OR is_spam``); see ``core.services.trashbin``. Items are + # permanently deleted from it in two ways: automatically once older than + # ``TRASHBIN_CUTOFF_DAYS`` (the ``cleanup_trashbin_task`` sweep), or manually + # when a user "empties" a folder — allowed only to the roles named by + # ``TRASHBIN_ALLOW_EMPTY``. + # + # 0 DISABLES automatic deletion entirely: the sweep becomes a no-op and + # trashbin items are kept until someone empties the folder by hand. (0 must + # not mean "delete everything nightly" — an operator reaching for 0 is + # turning the feature off, and the retention banner in the UI is hidden at + # 0 on exactly that reading.) + TRASHBIN_CUTOFF_DAYS = values.PositiveIntegerValue( 30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None ) + # 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 + ) + + # How long (seconds) a mailbox/organization's computed storage usage is + # cached at the service layer (the quota gauge and entitlements). The + # computation is ~100ms for a large mailbox, so caching keeps the sidebar + # gauge from recomputing on every load. Emptying the trashbin invalidates + # the mailbox entry so freed space shows immediately; every other change + # (new mail, sends) is reflected within this TTL. 0 disables the cache. + STORAGE_USAGE_CACHE_TTL = values.PositiveIntegerValue( + 60, environ_name="STORAGE_USAGE_CACHE_TTL", environ_prefix=None + ) AUTH_USER_MODEL = "core.User" diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 8ab8efd0..5b8cb44b 100644 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -18,6 +18,10 @@ "{{author}} unassigned themself and {{assignees}}_one": "{{author}} unassigned themself and {{assignees}}", "{{author}} unassigned themself and {{assignees}}_other": "{{author}} unassigned themself and {{assignees}}", "{{author}} unassigned you": "{{author}} unassigned you", + "{{count}} messages have been permanently deleted._one": "{{count}} message has been permanently deleted.", + "{{count}} messages have been permanently deleted._other": "{{count}} messages have been permanently deleted.", + "{{count}} spam messages have been permanently deleted._one": "{{count}} spam message has been permanently deleted.", + "{{count}} spam messages have been permanently deleted._other": "{{count}} spam messages have been permanently deleted.", "{{author}} unassigned you and {{assignees}}_one": "{{author}} unassigned you and {{assignees}}", "{{author}} unassigned you and {{assignees}}_other": "{{author}} unassigned you and {{assignees}}", "{{author}} unassigned you and themself": "{{author}} unassigned you and themself", @@ -340,6 +344,7 @@ "Delete internal comment": "Delete internal comment", "Delete label \"{{label}}\"": "Delete label \"{{label}}\"", "Delete mailbox {{mailbox}}": "Delete mailbox {{mailbox}}", + "Delete permanently": "Delete permanently", "Delete signature \"{{signature}}\"": "Delete signature \"{{signature}}\"", "Delete template \"{{template}}\"": "Delete template \"{{template}}\"", "Delete the messages of \"{{name}}\"": "Delete the messages of \"{{name}}\"", @@ -946,6 +951,9 @@ "You and {{assignees}} were unassigned_other": "You and {{assignees}} were unassigned", "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.", "You are about to leave this page and be redirected to:": "You are about to leave this page and be redirected to:", + "You are about to permanently delete every message in the spam folder. This cannot be undone.": "You are about to permanently delete every message in the spam folder. This cannot be undone.", + "You are about to permanently delete every message in the trash. This cannot be undone.": "You are about to permanently delete every message in the trash. This cannot be undone.", + "You are about to permanently delete the selected messages. This cannot be undone.": "You are about to permanently delete the selected messages. This cannot be undone.", "You are signed in with the address {{email}}, but this account has not been configured to use the service.": "You are signed in with the address {{email}}, but this account has not been configured to use the service.", "You are the last editor of this thread, you cannot therefore modify your access.": "You are the last editor of this thread, you cannot therefore modify your access.", "You assigned {{assignees}}_one": "You assigned {{assignees}}", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index c9ac8f5f..9e82316d 100644 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -25,6 +25,10 @@ "{{author}} unassigned themself and {{assignees}}_many": "{{author}} s'est désassigné·e ainsi que {{assignees}}", "{{author}} unassigned themself and {{assignees}}_other": "{{author}} s'est désassigné·e ainsi que {{assignees}}", "{{author}} unassigned you": "{{author}} vous a désassigné·e", + "{{count}} messages have been permanently deleted._one": "{{count}} message a été supprimé définitivement.", + "{{count}} messages have been permanently deleted._other": "{{count}} messages ont été supprimés définitivement.", + "{{count}} spam messages have been permanently deleted._one": "{{count}} message indésirable a été supprimé définitivement.", + "{{count}} spam messages have been permanently deleted._other": "{{count}} messages indésirables ont été supprimés définitivement.", "{{author}} unassigned you and {{assignees}}_one": "{{author}} vous a désassigné·e ainsi que {{assignees}}", "{{author}} unassigned you and {{assignees}}_many": "{{author}} vous a désassigné·e ainsi que {{assignees}}", "{{author}} unassigned you and {{assignees}}_other": "{{author}} vous a désassigné·e ainsi que {{assignees}}", @@ -415,6 +419,7 @@ "Delete internal comment": "Supprimer le commentaire interne", "Delete label \"{{label}}\"": "Supprimer le libellé \"{{label}}\"", "Delete mailbox {{mailbox}}": "Supprimer la boîte aux lettres {{mailbox}}", + "Delete permanently": "Supprimer définitivement", "Delete signature \"{{signature}}\"": "Supprimer la signature \"{{signature}}\"", "Delete template \"{{template}}\"": "Supprimer le modèle \"{{template}}\"", "Delete the messages of \"{{name}}\"": "Supprimer les messages de « {{name}} »", @@ -1035,6 +1040,9 @@ "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.", + "You are about to permanently delete every message in the trash. This cannot be undone.": "Vous êtes sur le point de supprimer définitivement tous les messages de la corbeille. Cette action est irréversible.", + "You are about to permanently delete the selected messages. This cannot be undone.": "Vous êtes sur le point de supprimer définitivement les messages sélectionnés. Cette action est irréversible.", "You are signed in with the address {{email}}, but this account has not been configured to use the service.": "Vous êtes bien connecté avec l'adresse {{email}}, mais ce compte n'a pas été configuré pour pouvoir utiliser le service.", "You are the last editor of this thread, you cannot therefore modify your access.": "Vous êtes le dernier éditeur de cette conversation, vous ne pouvez donc pas modifier votre accès.", "You assigned {{assignees}}_one": "Vous avez assigné {{assignees}}", diff --git a/src/frontend/public/locales/common/nl-NL.json b/src/frontend/public/locales/common/nl-NL.json index ede9dff7..931ce2e4 100644 --- a/src/frontend/public/locales/common/nl-NL.json +++ b/src/frontend/public/locales/common/nl-NL.json @@ -18,6 +18,10 @@ "{{author}} unassigned themself and {{assignees}}_one": "", "{{author}} unassigned themself and {{assignees}}_other": "", "{{author}} unassigned you": "", + "{{count}} messages have been permanently deleted._one": "{{count}} bericht is definitief verwijderd.", + "{{count}} messages have been permanently deleted._other": "{{count}} berichten zijn definitief verwijderd.", + "{{count}} spam messages have been permanently deleted._one": "{{count}} spambericht is definitief verwijderd.", + "{{count}} spam messages have been permanently deleted._other": "{{count}} spamberichten zijn definitief verwijderd.", "{{author}} unassigned you and {{assignees}}_one": "", "{{author}} unassigned you and {{assignees}}_other": "", "{{author}} unassigned you and themself": "", @@ -342,6 +346,7 @@ "Delete internal comment": "", "Delete label \"{{label}}\"": "Verwijder label \"{{label}}\"", "Delete mailbox {{mailbox}}": "Verwijder mailbox {{mailbox}}", + "Delete permanently": "Definitief verwijderen", "Delete signature \"{{signature}}\"": "Verwijder handtekening \"{{signature}}\"", "Delete template \"{{template}}\"": "Sjabloon \"{{template}} \" verwijderen", "Delete the messages of \"{{name}}\"": "Berichten van \"{{name}}\" verwijderen", @@ -937,6 +942,9 @@ "You and {{assignees}} were unassigned_other": "", "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "", "You are about to leave this page and be redirected to:": "", + "You are about to permanently delete every message in the spam folder. This cannot be undone.": "Je staat op het punt alle berichten in de spammap definitief te verwijderen. Dit kan niet ongedaan worden gemaakt.", + "You are about to permanently delete every message in the trash. This cannot be undone.": "Je staat op het punt alle berichten in de prullenbak definitief te verwijderen. Dit kan niet ongedaan worden gemaakt.", + "You are about to permanently delete the selected messages. This cannot be undone.": "Je staat op het punt de geselecteerde berichten definitief te verwijderen. Dit kan niet ongedaan worden gemaakt.", "You are signed in with the address {{email}}, but this account has not been configured to use the service.": "", "You are the last editor of this thread, you cannot therefore modify your access.": "U bent de laatste redacteur van dit kanaal, daarom kunt u uw toegang niet aanpassen.", "You assigned {{assignees}}_one": "", diff --git a/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts b/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts index 3fcc55e8..bb6a6b6d 100644 --- a/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts +++ b/src/frontend/src/features/api/gen/mailboxes/mailboxes.ts @@ -23,7 +23,11 @@ import type { import type { Mailbox, + MailboxEmptyTrashRequestRequest, + MailboxEmptyTrashResponse, + MailboxEntitlements, MailboxLight, + MailboxStorageStats, MailboxesImageProxyListParams, MailboxesMessageTemplatesAvailableListParams, MailboxesMessageTemplatesListParams, @@ -2486,6 +2490,316 @@ export const useMailboxesPartialUpdate = < return useMutation(mutationOptions, queryClient); }; +/** + * Permanently delete trashed or spam messages in the mailbox (pick the folder with `scope`). Deletes the whole folder by default, or only the items named by `thread_ids` / `message_ids`. This cannot be undone. Allowed only to the roles named by the TRASHBIN_ALLOW_EMPTY policy. + */ +export type mailboxesEmptyTrashCreateResponse200 = { + data: MailboxEmptyTrashResponse; + status: 200; +}; + +export type mailboxesEmptyTrashCreateResponse403 = { + data: void; + status: 403; +}; + +export type mailboxesEmptyTrashCreateResponseSuccess = + mailboxesEmptyTrashCreateResponse200 & { + headers: Headers; + }; +export type mailboxesEmptyTrashCreateResponseError = + mailboxesEmptyTrashCreateResponse403 & { + headers: Headers; + }; + +export type mailboxesEmptyTrashCreateResponse = + | mailboxesEmptyTrashCreateResponseSuccess + | mailboxesEmptyTrashCreateResponseError; + +export const getMailboxesEmptyTrashCreateUrl = (id: string) => { + return `/api/v1.0/mailboxes/${id}/empty-trash/`; +}; + +export const mailboxesEmptyTrashCreate = async ( + id: string, + mailboxEmptyTrashRequestRequest: MailboxEmptyTrashRequestRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesEmptyTrashCreateUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(mailboxEmptyTrashRequestRequest), + }, + ); +}; + +export const getMailboxesEmptyTrashCreateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: MailboxEmptyTrashRequestRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: string; data: MailboxEmptyTrashRequestRequest }, + TContext +> => { + const mutationKey = ["mailboxesEmptyTrashCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: string; data: MailboxEmptyTrashRequestRequest } + > = (props) => { + const { id, data } = props ?? {}; + + return mailboxesEmptyTrashCreate(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MailboxesEmptyTrashCreateMutationResult = NonNullable< + Awaited> +>; +export type MailboxesEmptyTrashCreateMutationBody = + MailboxEmptyTrashRequestRequest; +export type MailboxesEmptyTrashCreateMutationError = ErrorType; + +export const useMailboxesEmptyTrashCreate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: MailboxEmptyTrashRequestRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { id: string; data: MailboxEmptyTrashRequestRequest }, + TContext +> => { + const mutationOptions = getMailboxesEmptyTrashCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * Return storage entitlements (usage and limits) for the mailbox. + +Quotas live on the mailbox, not the user. Access is restricted to the +mailbox's own members through ``get_object`` (the viewset queryset is +already filtered to the current user's mailboxes). + +When the entitlements backend is unavailable, the gauge is degraded +rather than erroring the request: usage falls back to 0 and limits to +null, which hides the gauge on the frontend. + */ +export type mailboxesEntitlementsRetrieveResponse200 = { + data: MailboxEntitlements; + status: 200; +}; + +export type mailboxesEntitlementsRetrieveResponseSuccess = + mailboxesEntitlementsRetrieveResponse200 & { + headers: Headers; + }; +export type mailboxesEntitlementsRetrieveResponse = + mailboxesEntitlementsRetrieveResponseSuccess; + +export const getMailboxesEntitlementsRetrieveUrl = (id: string) => { + return `/api/v1.0/mailboxes/${id}/entitlements/`; +}; + +export const mailboxesEntitlementsRetrieve = async ( + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesEntitlementsRetrieveUrl(id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMailboxesEntitlementsRetrieveQueryKey = (id?: string) => { + return [`/api/v1.0/mailboxes/${id}/entitlements/`] as const; +}; + +export const getMailboxesEntitlementsRetrieveQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getMailboxesEntitlementsRetrieveQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + mailboxesEntitlementsRetrieve(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MailboxesEntitlementsRetrieveQueryResult = NonNullable< + Awaited> +>; +export type MailboxesEntitlementsRetrieveQueryError = ErrorType; + +export function useMailboxesEntitlementsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesEntitlementsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesEntitlementsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMailboxesEntitlementsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMailboxesEntitlementsRetrieveQueryOptions( + id, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + /** * Search mailboxes by domain, local part and contact name. @@ -2698,3 +3012,195 @@ export function useMailboxesSearchList< return query; } + +/** + * 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. + */ +export type mailboxesStorageRetrieveResponse200 = { + data: MailboxStorageStats; + status: 200; +}; + +export type mailboxesStorageRetrieveResponseSuccess = + mailboxesStorageRetrieveResponse200 & { + headers: Headers; + }; +export type mailboxesStorageRetrieveResponse = + mailboxesStorageRetrieveResponseSuccess; + +export const getMailboxesStorageRetrieveUrl = (id: string) => { + return `/api/v1.0/mailboxes/${id}/storage/`; +}; + +export const mailboxesStorageRetrieve = async ( + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getMailboxesStorageRetrieveUrl(id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getMailboxesStorageRetrieveQueryKey = (id?: string) => { + return [`/api/v1.0/mailboxes/${id}/storage/`] as const; +}; + +export const getMailboxesStorageRetrieveQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getMailboxesStorageRetrieveQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + mailboxesStorageRetrieve(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type MailboxesStorageRetrieveQueryResult = NonNullable< + Awaited> +>; +export type MailboxesStorageRetrieveQueryError = ErrorType; + +export function useMailboxesStorageRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesStorageRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useMailboxesStorageRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useMailboxesStorageRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getMailboxesStorageRetrieveQueryOptions(id, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} diff --git a/src/frontend/src/features/api/gen/models/config_retrieve200.ts b/src/frontend/src/features/api/gen/models/config_retrieve200.ts index 596fe074..a5fc2b66 100644 --- a/src/frontend/src/features/api/gen/models/config_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/config_retrieve200.ts @@ -41,6 +41,8 @@ export type ConfigRetrieve200 = { readonly FEATURE_MAILDOMAIN_MANAGE_ACCESSES: boolean; readonly FEATURE_THREAD_SPLIT: boolean; readonly FEATURE_MAILDOMAIN_MANAGE_TOTP: boolean; + /** Number of days after which trashed and spam messages are permanently and automatically deleted. */ + readonly TRASHBIN_CUTOFF_DAYS: number; /** Maximum age in seconds for a message to be eligible for manual retry of failed deliveries */ readonly MESSAGES_MANUAL_RETRY_MAX_AGE: number; /** Whether silent OIDC login is enabled */ diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index d54144f3..1b63e916 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -64,6 +64,7 @@ export * from "./label_request"; export * from "./labels_add_threads_create_body"; export * from "./labels_list_params"; export * from "./labels_remove_threads_create_body"; +export * from "./largest_thread"; export * from "./mail_domain_access_role_choices"; export * from "./mail_domain_admin"; export * from "./mail_domain_admin_abilities"; @@ -87,9 +88,15 @@ export * from "./mailbox_admin_mandatory_totp_payload_request"; export * from "./mailbox_admin_mandatory_totp_response"; export * from "./mailbox_admin_reset_totp_response"; export * from "./mailbox_admin_update_metadata_request"; +export * from "./mailbox_empty_trash_request_request"; +export * from "./mailbox_empty_trash_request_scope_enum"; +export * from "./mailbox_empty_trash_response"; +export * from "./mailbox_entitlements"; +export * from "./mailbox_entitlements_organization"; export * from "./mailbox_light"; export * from "./mailbox_role"; export * from "./mailbox_role_choices"; +export * from "./mailbox_storage_stats"; export * from "./mailboxes_accesses_list_params"; export * from "./mailboxes_calendar_add_create400"; export * from "./mailboxes_calendar_add_create503"; @@ -161,7 +168,6 @@ export * from "./reset_password_internal_server_error"; export * from "./reset_password_not_found"; export * from "./reset_password_response"; export * from "./response_enum"; -export * from "./scope_enum"; export * from "./scope_level_enum"; export * from "./send_create400"; export * from "./send_create403"; @@ -170,6 +176,7 @@ export * from "./send_message_request"; export * from "./send_message_response"; export * from "./source_enum"; export * from "./status_enum"; +export * from "./storage_entitlement"; export * from "./task_status_response"; export * from "./task_status_response_result"; export * from "./third_party_drive_retrieve_params"; @@ -180,6 +187,7 @@ export * from "./thread_access_detail"; export * from "./thread_access_request"; export * from "./thread_access_role_choices"; export * from "./thread_bulk_delete_request_request"; +export * from "./thread_bulk_delete_request_scope_enum"; export * from "./thread_event"; export * from "./thread_event_assignees_data"; export * from "./thread_event_assignees_data_request"; diff --git a/src/frontend/src/features/api/gen/models/largest_thread.ts b/src/frontend/src/features/api/gen/models/largest_thread.ts new file mode 100644 index 00000000..96144b9a --- /dev/null +++ b/src/frontend/src/features/api/gen/models/largest_thread.ts @@ -0,0 +1,31 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serializer for a thread entry in the mailbox storage stats. + */ +export interface LargestThread { + /** Thread UUID */ + id: string; + /** + * Thread subject + * @nullable + */ + subject: string | null; + /** Total compressed blob size of the thread in bytes */ + size: number; + /** Number of messages in the thread */ + message_count: number; + /** + * Date of the last message in the thread + * @nullable + */ + messaged_at: string | null; + /** Whether the thread is unread for this mailbox */ + is_unread: boolean; +} diff --git a/src/frontend/src/features/api/gen/models/mailbox_abilities.ts b/src/frontend/src/features/api/gen/models/mailbox_abilities.ts index 25a6dc7f..bbc1bed2 100644 --- a/src/frontend/src/features/api/gen/models/mailbox_abilities.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_abilities.ts @@ -32,4 +32,6 @@ export type MailboxAbilities = { readonly manage_message_templates: boolean; /** Can import messages */ readonly import_messages: boolean; + /** Can empty the trashbin (trashed + spam) */ + readonly empty_trash: boolean; }; diff --git a/src/frontend/src/features/api/gen/models/mailbox_empty_trash_request_request.ts b/src/frontend/src/features/api/gen/models/mailbox_empty_trash_request_request.ts new file mode 100644 index 00000000..f995276c --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_empty_trash_request_request.ts @@ -0,0 +1,37 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { MailboxEmptyTrashRequestScopeEnum } from "./mailbox_empty_trash_request_scope_enum"; + +/** + * Payload for the "empty trashbin" endpoint. + +The trashbin is the union of trashed and spam messages; ``scope`` picks the +folder to act on ("trashed" or "spam") so the two are handled independently. + +``thread_ids`` / ``message_ids`` narrow the deletion to specific items. +Both omitted (the default) means the whole folder — note this is the +opposite of ``ThreadBulkDeleteRequestSerializer``, where omitting every +target is an error: there, "no targets" is a malformed request; here it is +the primary use case ("Empty trash"). + +Permanently deleting one selected message and emptying the entire folder +are the same privilege, so they deliberately share this one endpoint rather +than being split across two that could drift apart — see the note on +``ThreadBulkDeleteRequestSerializer.BULK_DELETE_SCOPE_FILTERS``. + */ +export interface MailboxEmptyTrashRequestRequest { + /** Which half of the trashbin to permanently delete: 'trashed' or 'spam'. + +* `trashed` - trashed +* `spam` - spam */ + scope: MailboxEmptyTrashRequestScopeEnum; + /** Restrict the deletion to these threads. Omit to empty the whole folder. */ + thread_ids?: string[]; + /** Restrict the deletion to these messages (still scope-filtered). Omit to empty the whole folder. */ + message_ids?: string[]; +} diff --git a/src/frontend/src/features/api/gen/models/mailbox_empty_trash_request_scope_enum.ts b/src/frontend/src/features/api/gen/models/mailbox_empty_trash_request_scope_enum.ts new file mode 100644 index 00000000..68c0918f --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_empty_trash_request_scope_enum.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * * `trashed` - trashed + * `spam` - spam + */ +export type MailboxEmptyTrashRequestScopeEnum = + (typeof MailboxEmptyTrashRequestScopeEnum)[keyof typeof MailboxEmptyTrashRequestScopeEnum]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const MailboxEmptyTrashRequestScopeEnum = { + trashed: "trashed", + spam: "spam", +} as const; diff --git a/src/frontend/src/features/api/gen/models/mailbox_empty_trash_response.ts b/src/frontend/src/features/api/gen/models/mailbox_empty_trash_response.ts new file mode 100644 index 00000000..14a7339b --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_empty_trash_response.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Response for the "empty trashbin" endpoint. + */ +export interface MailboxEmptyTrashResponse { + success: boolean; + /** Number of messages permanently deleted. */ + deleted_count: number; +} diff --git a/src/frontend/src/features/api/gen/models/mailbox_entitlements.ts b/src/frontend/src/features/api/gen/models/mailbox_entitlements.ts new file mode 100644 index 00000000..6f375c11 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_entitlements.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { StorageEntitlement } from "./storage_entitlement"; +import type { MailboxEntitlementsOrganization } from "./mailbox_entitlements_organization"; + +/** + * Storage entitlements for a mailbox, at the account and organization levels. + */ +export interface MailboxEntitlements { + readonly account: StorageEntitlement; + /** @nullable */ + readonly organization: MailboxEntitlementsOrganization; +} diff --git a/src/frontend/src/features/api/gen/models/mailbox_entitlements_organization.ts b/src/frontend/src/features/api/gen/models/mailbox_entitlements_organization.ts new file mode 100644 index 00000000..8c54a6ee --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_entitlements_organization.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { StorageEntitlement } from "./storage_entitlement"; + +/** + * @nullable + */ +export type MailboxEntitlementsOrganization = StorageEntitlement | null; diff --git a/src/frontend/src/features/api/gen/models/mailbox_storage_stats.ts b/src/frontend/src/features/api/gen/models/mailbox_storage_stats.ts new file mode 100644 index 00000000..77baedf1 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/mailbox_storage_stats.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { LargestThread } from "./largest_thread"; + +/** + * Serializer for mailbox storage statistics response. + */ +export interface MailboxStorageStats { + /** Total storage used by the mailbox in bytes */ + total_storage: number; + /** Storage used by trashed conversations in bytes */ + trashed_storage: number; + /** Storage used by spam conversations in bytes */ + spam_storage: number; + /** Total number of messages in the mailbox */ + message_count: number; + /** Total number of threads in the mailbox */ + thread_count: number; + /** Top 100 threads by storage size, ordered descending. */ + largest_threads: LargestThread[]; +} diff --git a/src/frontend/src/features/api/gen/models/storage_entitlement.ts b/src/frontend/src/features/api/gen/models/storage_entitlement.ts new file mode 100644 index 00000000..957d9f8e --- /dev/null +++ b/src/frontend/src/features/api/gen/models/storage_entitlement.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Storage usage and limit for a single level (mailbox or organization). + +``max_storage`` is nullable: null means "no known limit" and the +frontend hides the corresponding gauge. + */ +export interface StorageEntitlement { + /** Bytes currently used. */ + readonly storage_used: number; + /** + * Storage limit in bytes, or null when there is no limit. + * @nullable + */ + readonly max_storage: number | null; +} diff --git a/src/frontend/src/features/api/gen/models/thread_bulk_delete_request_request.ts b/src/frontend/src/features/api/gen/models/thread_bulk_delete_request_request.ts index 6278dc37..b5b4a69b 100644 --- a/src/frontend/src/features/api/gen/models/thread_bulk_delete_request_request.ts +++ b/src/frontend/src/features/api/gen/models/thread_bulk_delete_request_request.ts @@ -5,7 +5,7 @@ * This is the messages API schema. * OpenAPI spec version: 1.0.0 (v1.0) */ -import type { ScopeEnum } from "./scope_enum"; +import type { ThreadBulkDeleteRequestScopeEnum } from "./thread_bulk_delete_request_scope_enum"; /** * Payload for the bulk-delete endpoint: a scope and the threads/messages @@ -15,7 +15,7 @@ export interface ThreadBulkDeleteRequestRequest { /** Which messages to permanently delete. Only 'draft' (draft messages) is supported. * `draft` - draft */ - scope: ScopeEnum; + scope: ThreadBulkDeleteRequestScopeEnum; /** Threads whose scope-matching messages should be deleted. */ thread_ids?: string[]; /** Specific messages to delete (still scope-filtered). */ diff --git a/src/frontend/src/features/api/gen/models/scope_enum.ts b/src/frontend/src/features/api/gen/models/thread_bulk_delete_request_scope_enum.ts similarity index 58% rename from src/frontend/src/features/api/gen/models/scope_enum.ts rename to src/frontend/src/features/api/gen/models/thread_bulk_delete_request_scope_enum.ts index d1a602f0..fba82edd 100644 --- a/src/frontend/src/features/api/gen/models/scope_enum.ts +++ b/src/frontend/src/features/api/gen/models/thread_bulk_delete_request_scope_enum.ts @@ -9,9 +9,10 @@ /** * * `draft` - draft */ -export type ScopeEnum = (typeof ScopeEnum)[keyof typeof ScopeEnum]; +export type ThreadBulkDeleteRequestScopeEnum = + (typeof ThreadBulkDeleteRequestScopeEnum)[keyof typeof ThreadBulkDeleteRequestScopeEnum]; // eslint-disable-next-line @typescript-eslint/no-redeclare -export const ScopeEnum = { +export const ThreadBulkDeleteRequestScopeEnum = { draft: "draft", } as const; diff --git a/src/frontend/src/features/config/resolve.ts b/src/frontend/src/features/config/resolve.ts index 1ca4a91e..f272880a 100644 --- a/src/frontend/src/features/config/resolve.ts +++ b/src/frontend/src/features/config/resolve.ts @@ -255,6 +255,7 @@ export const resolveConfig = (api?: ConfigRetrieve200): AppConfig => { FEATURE_MAILDOMAIN_MANAGE_TOTP: api?.FEATURE_MAILDOMAIN_MANAGE_TOTP ?? false, FEATURE_THREAD_SPLIT: api?.FEATURE_THREAD_SPLIT ?? true, MESSAGES_MANUAL_RETRY_MAX_AGE: api?.MESSAGES_MANUAL_RETRY_MAX_AGE ?? 7 * 24 * 60 ** 2, + TRASHBIN_CUTOFF_DAYS: api?.TRASHBIN_CUTOFF_DAYS ?? 30, FRONTEND_SILENT_LOGIN_ENABLED: api?.FRONTEND_SILENT_LOGIN_ENABLED ?? false, DRIVE: api?.DRIVE ?? DEFAULT_DRIVE_CONFIG, LANGUAGES: languages, diff --git a/src/frontend/src/features/layouts/components/mailbox-panel/index.tsx b/src/frontend/src/features/layouts/components/mailbox-panel/index.tsx index 35b02bc9..241bd2cc 100644 --- a/src/frontend/src/features/layouts/components/mailbox-panel/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-panel/index.tsx @@ -9,6 +9,7 @@ import { MailboxLabels } from "./components/mailbox-labels"; import { MAILBOX_FOLDERS } from "./components/mailbox-list"; import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels"; import { MailboxSelector } from "@/features/layouts/components/mailbox-selector"; +import { QuotaWidget } from "@/features/quota/components/quota-widget"; export const MailboxPanel = () => { const navigate = useNavigate(); @@ -58,6 +59,7 @@ export const MailboxPanel = () => { )} + ) } diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss index c2de6d08..8da9beb4 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/_index.scss @@ -89,6 +89,225 @@ flex-direction: column; gap: var(--c--globals--spacings--2xs); } + + // --- Storage tab --------------------------------------------------------- + + // Quota gauge(s) at the top of the tab: one per level (mailbox, organization). + &__storage-gauges { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--s); + } + + &__storage-gauge { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--3xs); + + // Informational here (not a control), so drop the button affordance and the + // hover background the shared gauge applies by default. + .c__storage-gauge { + width: 100%; + cursor: default; + text-align: start; + + &:hover { + background: transparent; + } + } + } + + &__storage-gauge-caption { + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--neutral--secondary); + } + + // Prominent total-used headline, above the metric cards. Stays visible when + // storage is unlimited (no gauge), so usage is always shown here. + &__storage-total { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--3xs); + margin-bottom: var(--c--globals--spacings--md); + } + + &__storage-total-value { + font-size: var(--c--globals--font--sizes--h3); + font-weight: 700; + line-height: 1.1; + } + + &__storage-total-label { + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--neutral--secondary); + } + + &__storage-summary { + display: flex; + flex-wrap: nowrap; + gap: var(--c--globals--spacings--xs); + } + + // Tighten the gaps directly above (gauge → cards) and below (cards → list) + // the summary row so the cards sit closer to the gauge — 5px off each. + &__storage &__section { + margin-bottom: calc(var(--c--globals--spacings--lg) - 5px); + } + + &__storage-metric { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--4xs); + flex: 1 1 0; + min-width: 0; + padding: var(--c--globals--spacings--xs); + border: 1px solid var(--c--contextuals--border--semantic--neutral--tertiary); + border-radius: var(--c--globals--radius--sm); + } + + // Trash-and-spam metric doubles as a link to the Trash folder. + &__storage-metric--link { + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease, background-color 0.15s ease; + + &:hover { + border-color: var(--c--contextuals--border--semantic--neutral--secondary); + background-color: var(--c--contextuals--surface--semantic--neutral--secondary); + } + } + + &__storage-metric-value { + font-size: var(--c--globals--font--sizes--ml); + font-weight: 700; + } + + &__storage-metric-label { + font-size: var(--c--globals--font--sizes--xs); + color: var(--c--contextuals--content--semantic--neutral--secondary); + } + + // Largest-conversations list: one borderless row per thread, subject on the + // left with a muted message-count/date line, size right-aligned. + &__storage-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--4xs); + } + + // Row wrapper (a real
  • ): a transparent 1px border that fills in on + // hover/focus. Holds the deep-link anchor plus the trash action. + &__storage-item { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--2xs); + padding: var(--c--globals--spacings--xs); + border: 1px solid transparent; + border-radius: 4px; + + &:hover, + &:focus-within { + background-color: var( + --c--contextuals--background--semantic--neutral--tertiary + ); + border-color: var(--c--contextuals--border--semantic--neutral--tertiary); + } + } + + // The clickable info area: subject/meta on the left, size on the right. + &__storage-item-link { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--c--globals--spacings--s); + color: inherit; + text-decoration: none; + border-radius: 4px; + + &:focus-visible { + outline: 2px solid var(--c--contextuals--border--focus); + outline-offset: -2px; + } + } + + &__storage-item-trash { + flex-shrink: 0; + } + + // Read/unread bullet on the left of each row: a filled brand dot when unread, + // an empty slot when read so rows stay aligned. + &__storage-item-status { + flex-shrink: 0; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: transparent; + align-self: flex-start; + margin-top: 0.375rem; + } + + &__storage-item--unread { + .mailbox-settings__storage-item-status { + background-color: var( + --c--contextuals--background--semantic--brand--primary + ); + } + + .mailbox-settings__storage-item-subject { + font-weight: 700; + } + } + + &__storage-item-main { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--3xs); + min-width: 0; + } + + &__storage-item-subject { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25rem; + } + + &__storage-item-meta { + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--neutral--secondary); + } + + &__storage-item-size { + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--c--contextuals--content--semantic--neutral--secondary); + } + + // Shown when the mailbox has never stored anything. + &__storage-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--c--globals--spacings--2xs); + padding: var(--c--globals--spacings--2xl) var(--c--globals--spacings--md); + text-align: center; + } + + &__storage-empty-title { + margin: 0; + font-weight: 700; + } + + &__storage-empty-description { + margin: 0; + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--neutral--secondary); + } } // --- Imports tab ------------------------------------------------------------- diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx index 4af8dacd..c591f5f4 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/index.tsx @@ -21,6 +21,7 @@ import { MailboxSettingsMessageTemplatesTab } from "./message-templates-tab"; import { MailboxSettingsAutorepliesTab } from "./autoreplies-tab"; import { MailboxSettingsIntegrationsTab } from "./integrations-tab"; import { MailboxSettingsImportsTab, ImportsTabView } from "./imports-tab"; +import { MailboxSettingsStorageTab } from "./storage-tab"; import { ImportNewTitle } from "../imports-view/import-new-title"; export type SettingsTabId = @@ -30,7 +31,8 @@ export type SettingsTabId = | "message-templates" | "autoreplies" | "integrations" - | "imports"; + | "imports" + | "storage"; type ModalMailboxSettingsProps = { isOpen: boolean; @@ -168,6 +170,11 @@ export const ModalMailboxSettings = ({ ids.push("integrations"); } } + // Storage sits last, after every other category. Admin-only: it exposes the + // largest conversations and a trash action. + if (manage_accesses) { + ids.push("storage"); + } return ids; }, [settingsMailbox, isIntegrationsEnabled]); @@ -352,6 +359,22 @@ export const ModalMailboxSettings = ({ }, ] : []), + ...(availableTabIds.includes("storage") + ? [ + { + id: "storage", + label: t("Storage"), + title: t("Storage"), + content: ( + + ), + }, + ] + : []), ]; return ( diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/storage-tab/index.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/storage-tab/index.tsx new file mode 100644 index 00000000..06488dd7 --- /dev/null +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-mailbox-settings/storage-tab/index.tsx @@ -0,0 +1,260 @@ +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { Icon, IconType, Spinner, StorageGauge } from "@gouvfr-lasuite/ui-kit"; +import { Link } from "@tanstack/react-router"; +import clsx from "clsx"; +import { MouseEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import i18n from "@/features/i18n/initI18n"; +import { + Mailbox, + StorageEntitlement, + useMailboxesStorageRetrieve, +} from "@/features/api/gen"; +import useTrash from "@/features/message/use-trash"; +import { Banner } from "@/features/ui/components/banner"; +import { AttachmentHelper } from "@/features/utils/attachment-helper"; +import { DateHelper } from "@/features/utils/date-helper"; +import { useMailboxEntitlements } from "@/features/quota/api/use-mailbox-entitlements"; + +const BYTES_PER_GB = 1000 ** 3; + +type MailboxSettingsStorageTabProps = { + mailbox: Mailbox; + /** Closes the settings modal — called when the user follows a conversation + * deep-link so they land on the thread instead of behind the modal. */ + onClose: () => void; +}; + +/** + * Storage overview for a mailbox: the quota gauge (how much of the allowance is + * used, at the mailbox and — when relevant — organization level), the total + * space used with its trash/spam split, and the top-100 largest conversations + * so an admin can see what is taking up room, jump to a conversation, or move it + * to the trash. Reachable only by mailbox admins (the settings modal gates this + * tab on `manage_accesses`). + */ +export const MailboxSettingsStorageTab = ({ + mailbox, + onClose, +}: MailboxSettingsStorageTabProps) => { + const { t } = useTranslation(); + const language = i18n.resolvedLanguage; + + // Never surface a bare "0 B"/"0o": zero storage reads as "Empty". + const formatSize = (bytes: number) => + bytes === 0 ? t("Empty") : AttachmentHelper.getFormattedSize(bytes, language); + + const { data: entitlementsData } = useMailboxEntitlements(mailbox.id); + const { data, isLoading, error } = useMailboxesStorageRetrieve(mailbox.id); + + // `useTrash` shows a toast with a built-in Undo and handles cache + // invalidation. On success we just drop the row from the list; undoing from + // the toast restores it server-side and it reappears on the next refresh. + const { markAsTrashed } = useTrash(); + const [trashedIds, setTrashedIds] = useState>(new Set()); + + const handleTrash = (threadId: string) => { + markAsTrashed({ + threadIds: [threadId], + mailboxId: mailbox.id, + onSuccess: () => setTrashedIds((prev) => new Set(prev).add(threadId)), + }); + }; + + // Modifier/middle clicks open the conversation in a new tab — leave the modal + // open in that case; only a plain left click navigates in place and closes it. + const handleFollowLink = (event: MouseEvent) => { + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + event.button !== 0 + ) { + return; + } + onClose(); + }; + + const entitlements = entitlementsData?.data; + + if (isLoading) { + return ( +
    + }> + {t("Loading storage statistics...")} + +
    + ); + } + + if (error || !data) { + return ( +
    + + {t("Error while loading storage statistics")} + +
    + ); + } + + const stats = data.data; + + const unit = t("GB"); + // The account gauge already shows total-used-over-limit, so its caption *is* + // the "total storage used" label — no separate headline needed. + const accountGauge = entitlements + ? renderGauge(t("Total storage used"), entitlements.account, unit) + : null; + const organizationGauge = entitlements?.organization + ? renderGauge(t("Organization storage"), entitlements.organization, unit) + : null; + const gauges = [accountGauge, organizationGauge].filter(Boolean); + + return ( +
    + {gauges.length > 0 && ( +
    +
    {gauges}
    +
    + )} + +
    + {/* Fallback when storage is unlimited (no account gauge): the gauge + would otherwise be the only place the total appears. */} + {!accountGauge && ( +
    + + {formatSize(stats.total_storage)} + + + {t("Total storage used")} + +
    + )} +
    +
    + + {stats.message_count.toLocaleString(language)} + + + {t("Messages")} + +
    +
    + + {stats.thread_count.toLocaleString(language)} + + + {t("Conversations")} + +
    + {/* Links to the Trash folder so an admin can jump there and empty it + (the "Empty trash" action lives in the thread-list header). */} + + + {formatSize(stats.trashed_storage + stats.spam_storage)} + + + {t("Trash and spam")} + + +
    +
    + + {stats.message_count === 0 ? ( +
    +

    + {t("This mailbox is empty")} +

    +

    + {t("Storage usage will appear here once this mailbox has messages.")} +

    +
    + ) : ( +
    +
    +

    + {t("Largest conversations")} +

    +
    + +
      + {stats.largest_threads + .filter((thread) => !trashedIds.has(thread.id)) + .map((thread) => ( +
    • + + + + + {thread.subject || t("No subject")} + + + {t("{{count}} messages", { count: thread.message_count })} + {thread.messaged_at + ? ` · ${DateHelper.formatDate(thread.messaged_at, language, false)}` + : ""} + + + + {formatSize(thread.size)} + + +
    • + ))} +
    +
    + )} +
    + ); +}; + +const renderGauge = ( + caption: string, + level: StorageEntitlement, + unit: string, +) => { + // No gauge without a positive limit (null = unknown, 0 = unlimited); the + // usage summary below still conveys how much is stored. + if (level.max_storage == null || level.max_storage <= 0) { + return null; + } + return ( +
    + {caption} + +
    + ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-panel/_index.scss b/src/frontend/src/features/layouts/components/thread-panel/_index.scss index 62dfde38..ba21b336 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/_index.scss @@ -64,6 +64,12 @@ margin-left: auto; } + .thread-panel__trashbin-notice { + padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--sm) + var(--c--globals--spacings--2xs); + flex-shrink: 0; + } + .thread-panel__threads_list { overflow-y: auto; padding-inline: var(--c--globals--spacings--xs); diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx index ff846001..117a7f52 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx @@ -11,6 +11,7 @@ import useArchive from "@/features/message/use-archive"; import useSpam from "@/features/message/use-spam"; import useTrash from "@/features/message/use-trash"; import useDeleteDrafts from "@/features/message/use-delete-drafts"; +import useEmptyTrash from "@/features/message/use-empty-trash"; import useStarred from "@/features/message/use-starred"; import useCanEditThreads from "@/features/message/use-can-edit-threads"; import { ThreadPanelFilter } from "./thread-panel-filter"; @@ -38,6 +39,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is const { markAsArchived, markAsUnarchived } = useArchive(); const { markAsTrashed, markAsUntrashed } = useTrash(); const { deleteDrafts } = useDeleteDrafts(); + const { emptyTrashbin } = useEmptyTrash(); const { markAsSpam, markAsNotSpam } = useSpam(); const { markAsStarred, markAsUnstarred } = useStarred(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -98,6 +100,13 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is const canReportSpam = canEditSelection && !isTrashedView && !isSentView && !isDraftsView; const canTrash = canEditSelection && !isDraftsView; const canDeleteDrafts = canEditSelection && isDraftsView; + // "Empty trashbin" is a folder-level action (deletes the whole Trash or Spam + // folder), independent of any thread selection. The backend `empty_trash` + // ability encodes the TRASHBIN_ALLOW_EMPTY policy. (Hook called + // unconditionally; the view check gates the button, not the hook.) + const canEmptyTrashbin = useAbility(Abilities.CAN_EMPTY_TRASH, selectedMailbox); + const canEmptyTrash = (isTrashedView || isSpamView) && canEmptyTrashbin; + const emptyTrashLabel = isSpamView ? t('Empty spam') : t('Empty trash'); const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox); const canAssignLabel = canManageLabels && !isSpamView && !isTrashedView && !isDraftsView; const hasSelectionActions = canArchive || canReportSpam || canTrash || canDeleteDrafts || canAssignLabel; @@ -199,6 +208,33 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is aria-label={mainReadTooltip} /> + {canEmptyTrash && ( + +