(quotas) add usage+quota gauge in front + empty trash button

This commit is contained in:
Sylvain Zimmer
2026-07-27 22:33:32 +02:00
parent cf0b70e7ee
commit bd2be6ae12
61 changed files with 4469 additions and 128 deletions
+3 -1
View File
@@ -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
+335 -9
View File
@@ -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.",
+145 -3
View File
@@ -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)
+10
View File
@@ -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",
{
+52 -4
View File
@@ -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.
+235 -6
View File
@@ -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})
+2 -58
View File
@@ -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":
+12 -25
View File
@@ -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})
+20
View File
@@ -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)
@@ -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,
}
@@ -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
@@ -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}
+22
View File
@@ -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"
+21 -8
View File
@@ -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
@@ -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",
),
),
]
+39 -1
View File
@@ -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)"
+205
View File
@@ -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)
+184
View File
@@ -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}
+1
View File
@@ -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
@@ -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,
}
@@ -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()
@@ -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,
}
@@ -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
@@ -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 ---
@@ -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)
@@ -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="<live@example.com>")
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):
@@ -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
@@ -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()
+8
View File
@@ -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
+30 -1
View File
@@ -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"
@@ -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}}",
@@ -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}}",
@@ -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": "",
@@ -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<mailboxesEmptyTrashCreateResponse> => {
return fetchAPI<mailboxesEmptyTrashCreateResponse>(
getMailboxesEmptyTrashCreateUrl(id),
{
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(mailboxEmptyTrashRequestRequest),
},
);
};
export const getMailboxesEmptyTrashCreateMutationOptions = <
TError = ErrorType<void>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mailboxesEmptyTrashCreate>>,
TError,
{ id: string; data: MailboxEmptyTrashRequestRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof mailboxesEmptyTrashCreate>>,
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<ReturnType<typeof mailboxesEmptyTrashCreate>>,
{ id: string; data: MailboxEmptyTrashRequestRequest }
> = (props) => {
const { id, data } = props ?? {};
return mailboxesEmptyTrashCreate(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type MailboxesEmptyTrashCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof mailboxesEmptyTrashCreate>>
>;
export type MailboxesEmptyTrashCreateMutationBody =
MailboxEmptyTrashRequestRequest;
export type MailboxesEmptyTrashCreateMutationError = ErrorType<void>;
export const useMailboxesEmptyTrashCreate = <
TError = ErrorType<void>,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof mailboxesEmptyTrashCreate>>,
TError,
{ id: string; data: MailboxEmptyTrashRequestRequest },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof mailboxesEmptyTrashCreate>>,
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<mailboxesEntitlementsRetrieveResponse> => {
return fetchAPI<mailboxesEntitlementsRetrieveResponse>(
getMailboxesEntitlementsRetrieveUrl(id),
{
...options,
method: "GET",
},
);
};
export const getMailboxesEntitlementsRetrieveQueryKey = (id?: string) => {
return [`/api/v1.0/mailboxes/${id}/entitlements/`] as const;
};
export const getMailboxesEntitlementsRetrieveQueryOptions = <
TData = Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getMailboxesEntitlementsRetrieveQueryKey(id);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>
> = ({ signal }) =>
mailboxesEntitlementsRetrieve(id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type MailboxesEntitlementsRetrieveQueryResult = NonNullable<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>
>;
export type MailboxesEntitlementsRetrieveQueryError = ErrorType<unknown>;
export function useMailboxesEntitlementsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesEntitlementsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesEntitlementsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesEntitlementsRetrieve<
TData = Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesEntitlementsRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getMailboxesEntitlementsRetrieveQueryOptions(
id,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<mailboxesStorageRetrieveResponse> => {
return fetchAPI<mailboxesStorageRetrieveResponse>(
getMailboxesStorageRetrieveUrl(id),
{
...options,
method: "GET",
},
);
};
export const getMailboxesStorageRetrieveQueryKey = (id?: string) => {
return [`/api/v1.0/mailboxes/${id}/storage/`] as const;
};
export const getMailboxesStorageRetrieveQueryOptions = <
TData = Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getMailboxesStorageRetrieveQueryKey(id);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>
> = ({ signal }) =>
mailboxesStorageRetrieve(id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type MailboxesStorageRetrieveQueryResult = NonNullable<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>
>;
export type MailboxesStorageRetrieveQueryError = ErrorType<unknown>;
export function useMailboxesStorageRetrieve<
TData = Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesStorageRetrieve<
TData = Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>
>,
"initialData"
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesStorageRetrieve<
TData = Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useMailboxesStorageRetrieve<
TData = Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError = ErrorType<unknown>,
>(
id: string,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof mailboxesStorageRetrieve>>,
TError,
TData
>
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getMailboxesStorageRetrieveQueryOptions(id, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}
@@ -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 */
@@ -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";
@@ -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;
}
@@ -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;
};
@@ -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[];
}
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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[];
}
@@ -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;
}
@@ -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). */
@@ -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;
@@ -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,
@@ -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 = () => {
</Panel>
</Group>
)}
<QuotaWidget mailboxId={selectedMailbox?.id} />
</div>
)
}
@@ -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 <li>): 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 -------------------------------------------------------------
@@ -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: (
<MailboxSettingsStorageTab
key={settingsMailbox.id}
mailbox={settingsMailbox}
onClose={onClose}
/>
),
},
]
: []),
];
return (
@@ -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<Set<string>>(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 (
<div className="mailbox-settings__tab mailbox-settings__storage">
<Banner type="info" icon={<Spinner />}>
{t("Loading storage statistics...")}
</Banner>
</div>
);
}
if (error || !data) {
return (
<div className="mailbox-settings__tab mailbox-settings__storage">
<Banner type="error">
{t("Error while loading storage statistics")}
</Banner>
</div>
);
}
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 (
<div className="mailbox-settings__tab mailbox-settings__storage">
{gauges.length > 0 && (
<section className="mailbox-settings__section">
<div className="mailbox-settings__storage-gauges">{gauges}</div>
</section>
)}
<section className="mailbox-settings__section">
{/* Fallback when storage is unlimited (no account gauge): the gauge
would otherwise be the only place the total appears. */}
{!accountGauge && (
<div className="mailbox-settings__storage-total">
<span className="mailbox-settings__storage-total-value">
{formatSize(stats.total_storage)}
</span>
<span className="mailbox-settings__storage-total-label">
{t("Total storage used")}
</span>
</div>
)}
<div className="mailbox-settings__storage-summary">
<div className="mailbox-settings__storage-metric">
<span className="mailbox-settings__storage-metric-value">
{stats.message_count.toLocaleString(language)}
</span>
<span className="mailbox-settings__storage-metric-label">
{t("Messages")}
</span>
</div>
<div className="mailbox-settings__storage-metric">
<span className="mailbox-settings__storage-metric-value">
{stats.thread_count.toLocaleString(language)}
</span>
<span className="mailbox-settings__storage-metric-label">
{t("Conversations")}
</span>
</div>
{/* Links to the Trash folder so an admin can jump there and empty it
(the "Empty trash" action lives in the thread-list header). */}
<Link
to="/mailbox/$mailboxId"
params={{ mailboxId: mailbox.id }}
search={{ has_trashed: "1" }}
className="mailbox-settings__storage-metric mailbox-settings__storage-metric--link"
onClick={handleFollowLink}
>
<span className="mailbox-settings__storage-metric-value">
{formatSize(stats.trashed_storage + stats.spam_storage)}
</span>
<span className="mailbox-settings__storage-metric-label">
{t("Trash and spam")}
</span>
</Link>
</div>
</section>
{stats.message_count === 0 ? (
<div className="mailbox-settings__storage-empty">
<p className="mailbox-settings__storage-empty-title">
{t("This mailbox is empty")}
</p>
<p className="mailbox-settings__storage-empty-description">
{t("Storage usage will appear here once this mailbox has messages.")}
</p>
</div>
) : (
<section className="mailbox-settings__section">
<header className="mailbox-settings__section-header">
<h3 className="mailbox-settings__section-title">
{t("Largest conversations")}
</h3>
</header>
<ul className="mailbox-settings__storage-list">
{stats.largest_threads
.filter((thread) => !trashedIds.has(thread.id))
.map((thread) => (
<li
key={thread.id}
className={clsx("mailbox-settings__storage-item", {
"mailbox-settings__storage-item--unread": thread.is_unread,
})}
>
<span
className="mailbox-settings__storage-item-status"
aria-label={thread.is_unread ? t("Unread") : t("Read")}
/>
<Link
to="/mailbox/$mailboxId/thread/$threadId"
params={{ mailboxId: mailbox.id, threadId: thread.id }}
className="mailbox-settings__storage-item-link"
onClick={handleFollowLink}
>
<span className="mailbox-settings__storage-item-main">
<span className="mailbox-settings__storage-item-subject">
{thread.subject || t("No subject")}
</span>
<span className="mailbox-settings__storage-item-meta">
{t("{{count}} messages", { count: thread.message_count })}
{thread.messaged_at
? ` · ${DateHelper.formatDate(thread.messaged_at, language, false)}`
: ""}
</span>
</span>
<span className="mailbox-settings__storage-item-size">
{formatSize(thread.size)}
</span>
</Link>
<Button
className="mailbox-settings__storage-item-trash"
size="nano"
variant="tertiary"
aria-label={t("Move to trash")}
icon={<Icon name="delete" type={IconType.OUTLINED} />}
onClick={() => handleTrash(thread.id)}
/>
</li>
))}
</ul>
</section>
)}
</div>
);
};
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 (
<div key={caption} className="mailbox-settings__storage-gauge">
<span className="mailbox-settings__storage-gauge-caption">{caption}</span>
<StorageGauge
used={level.storage_used / BYTES_PER_GB}
total={level.max_storage / BYTES_PER_GB}
unit={unit}
/>
</div>
);
};
@@ -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);
@@ -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}
/>
</Tooltip>
{canEmptyTrash && (
<Tooltip content={emptyTrashLabel}>
<Button
onClick={() => {
if (!selectedMailbox) return;
emptyTrashbin({
mailboxId: selectedMailbox.id,
scope: isSpamView ? 'spam' : 'trashed',
// Clear only once the delete went through. Unlike the
// other actions here, emptyTrashbin opens a confirmation
// modal first, so clearing up-front would discard the
// open thread and the checkbox selection even when the
// user backs out.
onSuccess: () => {
unselectThread();
onClearSelection();
},
});
}}
icon={<Icon name="delete_forever" type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
color="error"
aria-label={emptyTrashLabel}
/>
</Tooltip>
)}
{isSelectionMode && (
<>
{hasSelectionActions && <VerticalSeparator withPadding={false} />}
@@ -11,6 +11,9 @@ import { useThreadSelection } from "@/features/providers/thread-selection";
import { useScrollRestore } from "@/features/providers/scroll-restore";
import { useThreadPanelFilters } from "./hooks/use-thread-panel-filters";
import { useThreadListbox } from "./hooks/use-thread-listbox";
import ViewHelper from "@/features/utils/view-helper";
import { useConfig } from "@/features/providers/config";
import { Banner } from "@/features/ui/components/banner";
export const ThreadPanel = () => {
const { threads, queryStates, unselectThread, loadNextThreads, selectedThread, selectedMailbox } = useMailboxContext();
@@ -40,6 +43,12 @@ export const ThreadPanel = () => {
const { getItemProps, onKeyDown: handleListboxKeyDown, onBlur: handleListboxBlur } = useThreadListbox(threads?.results);
// The Trash and Spam folders are the trashbin: their messages are
// permanently deleted after TRASHBIN_CUTOFF_DAYS (see the backend
// cleanup_trashbin_task). Surface that retention policy at the top of the list.
const { TRASHBIN_CUTOFF_DAYS } = useConfig();
const isTrashbinView = ViewHelper.isTrashedView() || ViewHelper.isSpamView();
const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting && threads?.next && !queryStates.threads.isFetchingNextPage) {
@@ -108,6 +117,13 @@ export const ThreadPanel = () => {
onEnableSelectionMode={enableSelectionMode}
onDisableSelectionMode={clearSelection}
/>
{isTrashbinView && TRASHBIN_CUTOFF_DAYS > 0 && (
<div className="thread-panel__trashbin-notice">
<Banner type="info">
{t('Messages older than {{count}} days are automatically and permanently deleted.', { count: TRASHBIN_CUTOFF_DAYS })}
</Banner>
</div>
)}
{isEmpty ? (
<div className="thread-panel__empty">
<div>
@@ -1,5 +1,9 @@
import { useThreadsBulkDeleteCreate } from "@/features/api/gen";
import { Message, ScopeEnum, Thread } from "@/features/api/gen/models";
import {
Message,
ThreadBulkDeleteRequestScopeEnum,
Thread,
} from "@/features/api/gen/models";
import { addToast, ToasterItem } from "../ui/components/toaster";
import { useMailboxContext } from "../providers/mailbox";
@@ -30,7 +34,10 @@ const extractDeletedCount = (response: { data: unknown }): number | undefined =>
* never offers an "Undo" action.
* !!! Do not use this hook directly, use the specialized hooks instead !!!
*/
const useDelete = (scope: ScopeEnum, options?: UseDeleteOptions) => {
const useDelete = (
scope: ThreadBulkDeleteRequestScopeEnum,
options?: UseDeleteOptions,
) => {
const { unpinThreads } = useMailboxContext();
const { mutate, status } = useThreadsBulkDeleteCreate({
mutation: {
@@ -0,0 +1,117 @@
import { useModals } from "@gouvfr-lasuite/cunningham-react";
import { useTranslation } from "react-i18next";
import { useMailboxesEmptyTrashCreate } from "@/features/api/gen";
import { MailboxEmptyTrashRequestScopeEnum } from "@/features/api/gen/models";
import { errorToString } from "@/features/api/api-error";
import { Banner } from "@/features/ui/components/banner";
import { addToast, ToasterItem } from "../ui/components/toaster";
import { useMailboxContext } from "../providers/mailbox";
type EmptyTrashOptions = {
mailboxId: string;
scope: MailboxEmptyTrashRequestScopeEnum;
/** Restrict the deletion to these conversations. Omit to empty the folder. */
threadIds?: string[];
/** Restrict the deletion to these messages. Omit to empty the folder. */
messageIds?: string[];
onSuccess?: (deletedCount: number) => void;
};
/**
* Permanently delete from one folder of the trashbin (trashed OR spam). Unlike
* `useTrash` (a reversible soft-delete flag), this hard-deletes and cannot be
* undone so it always goes through a scary confirmation dialog first and its
* toast never offers "Undo".
*
* Deletes the whole folder by default; pass `threadIds`/`messageIds` to remove
* only a selection. Both go through the same backend action and the same
* `empty_trash` permission deleting one trashed message is exactly as
* irreversible as emptying the folder, so it is gated identically.
*/
const useEmptyTrash = () => {
const { t } = useTranslation();
const modals = useModals();
const { invalidateMailbox, invalidateThreadsStats } = useMailboxContext();
const { mutate, status } = useMailboxesEmptyTrashCreate();
const emptyTrashbin = async ({
mailboxId,
scope,
threadIds,
messageIds,
onSuccess,
}: EmptyTrashOptions) => {
const isSpam = scope === "spam";
// A selection was passed, so this is a targeted delete rather than a
// whole-folder wipe. Kept count-free on purpose: a plural key here would
// need _one/_other variants, and the surrounding file uses flat keys.
const isTargeted = Boolean(threadIds?.length || messageIds?.length);
const title = isTargeted
? t("Delete permanently")
: isSpam
? t("Empty spam")
: t("Empty trash");
const warning = isTargeted
? t("You are about to permanently delete the selected messages. This cannot be undone.")
: isSpam
? t("You are about to permanently delete every message in the spam folder. This cannot be undone.")
: t("You are about to permanently delete every message in the trash. This cannot be undone.");
const decision = await modals.deleteConfirmationModal({
title: <span className="c__modal__text--centered">{title}</span>,
children: (
<Banner type="warning">
{warning}
</Banner>
),
});
if (decision !== "delete") return;
mutate(
{
id: mailboxId,
data: {
scope,
...(threadIds?.length ? { thread_ids: threadIds } : {}),
...(messageIds?.length ? { message_ids: messageIds } : {}),
},
},
{
onSuccess: (response) => {
const data = response.data as { deleted_count?: number };
const deletedCount = data?.deleted_count ?? 0;
invalidateMailbox();
invalidateThreadsStats();
addToast(
<ToasterItem type="info">
<p>
{isSpam
? t("{{count}} spam messages have been permanently deleted.", {
count: deletedCount,
defaultValue_one: "{{count}} spam message has been permanently deleted.",
})
: t("{{count}} messages have been permanently deleted.", {
count: deletedCount,
defaultValue_one: "{{count}} message has been permanently deleted.",
})}
</p>
</ToasterItem>
);
onSuccess?.(deletedCount);
},
onError: (error) => {
addToast(
<ToasterItem type="error">
<p>{errorToString(error)}</p>
</ToasterItem>
);
},
}
);
};
return { emptyTrashbin, status };
};
export default useEmptyTrash;
@@ -0,0 +1,19 @@
import { useMailboxesEntitlementsRetrieve } from "@/features/api/gen/mailboxes/mailboxes";
/**
* Fetch storage entitlements (usage + limits) for a mailbox.
*
* Quotas live on the mailbox, not the user, so the widget always resolves
* entitlements through the mailbox currently being viewed. The query is
* disabled until a mailbox id is known.
*/
export const useMailboxEntitlements = (mailboxId: string | undefined) => {
return useMailboxesEntitlementsRetrieve(mailboxId as string, {
query: {
enabled: !!mailboxId,
// Usage changes slowly relative to a browsing session; avoid refetching
// the (potentially DeployCenter-backed) endpoint on every focus.
staleTime: 5 * 60 * 1000,
},
});
};
@@ -0,0 +1,32 @@
.quota-widget {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--c--globals--spacings--xs);
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm);
border-top: 1px solid var(--c--contextuals--border--default);
background: var(--c--contextuals--background--surface--secondary);
&__level {
display: flex;
flex-direction: column;
gap: var(--c--globals--spacings--3xs);
}
&__caption {
font-size: var(--c--globals--font--sizes--xs);
color: var(--c--contextuals--text--muted);
}
// The gauge is a button; by default it is informational here, so drop the
// affordance. Admins get a clickable variant that opens the Storage tab.
.c__storage-gauge {
width: 100%;
cursor: default;
text-align: start;
}
.quota-widget__gauge--clickable {
cursor: pointer;
}
}
@@ -0,0 +1,93 @@
import { StorageGauge } from "@gouvfr-lasuite/ui-kit";
import { useTranslation } from "react-i18next";
import { StorageEntitlement } from "@/features/api/gen/models";
import { useMailboxContext } from "@/features/providers/mailbox";
import { useModalStore } from "@/features/providers/modal-store";
import { MODAL_MAILBOX_SETTINGS_ID } from "@/features/layouts/components/mailbox-settings/modal-mailbox-settings";
import { useMailboxEntitlements } from "../api/use-mailbox-entitlements";
import "./quota-widget.scss";
const BYTES_PER_GB = 1000 ** 3;
type QuotaWidgetProps = {
mailboxId: string | undefined;
};
/**
* Storage gauge shown at the bottom of the sidebar.
*
* Renders one gauge for the mailbox ("account") and, when the mailbox belongs
* to an organization, a second gauge for the organization aggregate. A level
* whose ``max_storage`` is null has no known limit and is not rendered.
*
* Mailbox admins can click a gauge to open the Storage settings tab (the
* detailed breakdown lives there); for other members the gauge is informational
* only, since that tab is admin-gated.
*/
export const QuotaWidget = ({ mailboxId }: QuotaWidgetProps) => {
const { t } = useTranslation();
const { selectedMailbox } = useMailboxContext();
const { openModal } = useModalStore();
const { data } = useMailboxEntitlements(mailboxId);
const entitlements = data?.data;
if (!entitlements) {
return null;
}
const canOpenStorageTab = selectedMailbox?.abilities.manage_accesses ?? false;
const onOpen = canOpenStorageTab
? () => openModal(MODAL_MAILBOX_SETTINGS_ID, { initialTab: "storage" })
: undefined;
const unit = t("GB");
const account = renderGauge(entitlements.account, unit, onOpen);
const organization = entitlements.organization
? renderGauge(entitlements.organization, unit, onOpen)
: null;
if (!account && !organization) {
return null;
}
return (
<div className="quota-widget">
{account && (
<div className="quota-widget__level">
<span className="quota-widget__caption">{t("Mailbox storage")}</span>
{account}
</div>
)}
{organization && (
<div className="quota-widget__level">
<span className="quota-widget__caption">
{t("Organization storage")}
</span>
{organization}
</div>
)}
</div>
);
};
const renderGauge = (
level: StorageEntitlement,
unit: string,
onClick?: () => void,
) => {
// No gauge without a positive limit: null means "unknown" and 0 means
// "unlimited". Either way there is nothing to gauge against, so the sidebar
// widget is hidden (usage stays visible in the Storage settings tab).
if (level.max_storage == null || level.max_storage <= 0) {
return null;
}
return (
<StorageGauge
used={level.storage_used / BYTES_PER_GB}
total={level.max_storage / BYTES_PER_GB}
unit={unit}
onClick={onClick}
className={onClick ? "quota-widget__gauge--clickable" : undefined}
/>
);
};
+3 -1
View File
@@ -12,7 +12,8 @@ enum MailboxAbilities {
CAN_MANAGE_MAILBOX_LABELS = "manage_labels",
CAN_IMPORT_MESSAGES = "import_messages",
CAN_MANAGE_MESSAGE_TEMPLATES = "manage_message_templates",
CAN_MANAGE_ACCESSES = "manage_accesses"
CAN_MANAGE_ACCESSES = "manage_accesses",
CAN_EMPTY_TRASH = "empty_trash",
}
enum UserAbilities {
@@ -91,6 +92,7 @@ function useAbility(
case Abilities.CAN_MANAGE_MAILBOX_LABELS:
case Abilities.CAN_IMPORT_MESSAGES:
case Abilities.CAN_MANAGE_MESSAGE_TEMPLATES:
case Abilities.CAN_EMPTY_TRASH:
case Abilities.CAN_MANAGE_MAILDOMAIN_MAILBOXES:
case Abilities.CAN_MANAGE_MAILDOMAIN_ACCESSES:
case Abilities.CAN_MANAGE_SOME_MAILDOMAIN_ACCESSES: