🐛(global) enforce full edit rights on thread mutations

A user with VIEWER MailboxAccess on a shared mailbox could still mutate
threads that the mailbox had EDITOR ThreadAccess to: the permission
check only looked at ThreadAccess.role, never at MailboxAccess.role.

Both roles must now be satisfied (EDITOR on ThreadAccess AND a role in
MAILBOX_ROLES_CAN_EDIT on MailboxAccess) for archive, spam, trash,
label, split, refresh_summary and thread-event writes. Personal actions
(unread, starred) intentionally stay open to any mailbox access since
they only mutate the caller's own ThreadAccess row.

The rule is centralised in ThreadAccessQuerySet.editable_by(user,
mailbox_id) so viewsets and permission classes share a single source of
truth, and exposed to the frontend via a new Thread.abilities.edit
field consumed by use-ability, which gates the matching UI controls.
This commit is contained in:
jbpenrath
2026-04-09 11:23:56 +02:00
parent 13d34c213f
commit 01b45a69fb
37 changed files with 1155 additions and 150 deletions

View File

@@ -9055,9 +9055,17 @@
"events_count": {
"type": "integer",
"readOnly": true
},
"abilities": {
"type": "object",
"additionalProperties": {
"type": "boolean"
},
"readOnly": true
}
},
"required": [
"abilities",
"accesses",
"active_messaged_at",
"archived_messaged_at",

View File

@@ -123,17 +123,18 @@ class IsAllowedToAccess(IsAuthenticated):
is_list_action = hasattr(view, "action") and view.action == "list"
if not is_list_action:
# For create action on nested routes, check thread access with edit role
# For create action on nested routes, check full edit rights
# on the thread: EDITOR ThreadAccess role AND CAN_EDIT MailboxAccess role.
if (
thread_id_from_url
and hasattr(view, "action")
and view.action == "create"
):
return models.ThreadAccess.objects.filter(
thread_id=thread_id_from_url,
role__in=enums.THREAD_ROLES_CAN_EDIT,
mailbox__accesses__user=request.user,
).exists()
return (
models.ThreadAccess.objects.editable_by(request.user)
.filter(thread_id=thread_id_from_url)
.exists()
)
# Allow non-list actions (like detail views or specific APIViews like SendMessageView)
# to proceed to object-level checks or handle permissions within the view.
return True
@@ -323,6 +324,17 @@ class IsAllowedToManageThreadAccess(IsAuthenticated):
if obj.thread.id != view.kwargs.get("thread_id"):
return False
# Destroying a ThreadAccess removes the thread for every member of
# `obj.mailbox` (the row is unique per (thread, mailbox)). Require
# editor-level rights on that mailbox so a viewer cannot revoke
# access on behalf of the whole team. Thread role is irrelevant:
# a viewer on the thread but editor on the mailbox can still leave.
if view.action == "destroy":
return obj.mailbox.accesses.filter(
user=request.user,
role__in=enums.MAILBOX_ROLES_CAN_EDIT,
).exists()
return (
models.ThreadAccess.objects.select_related("mailbox")
.filter(
@@ -534,7 +546,15 @@ class IsGlobalChannelMixin:
class HasThreadEditAccess(IsAuthenticated):
"""Allows access only to users with EDITOR role on the thread via ThreadAccess."""
"""Allows access only to users with full edit rights on the thread.
Full edit rights require BOTH:
- `ThreadAccess.role == EDITOR` on the thread
- A `MailboxAccess.role` in `MAILBOX_ROLES_CAN_EDIT` on the same mailbox
This prevents a user with VIEWER MailboxAccess on a shared mailbox
from mutating threads that the mailbox has EDITOR ThreadAccess to.
"""
message = "You do not have permission to perform this action on this thread."
@@ -554,11 +574,11 @@ class HasThreadEditAccess(IsAuthenticated):
if thread_id_from_url is None:
return True
return models.ThreadAccess.objects.filter(
thread_id=thread_id_from_url,
role__in=enums.THREAD_ROLES_CAN_EDIT,
mailbox__accesses__user=request.user,
).exists()
return (
models.ThreadAccess.objects.editable_by(request.user)
.filter(thread_id=thread_id_from_url)
.exists()
)
def has_object_permission(self, request, view, obj):
"""Check editor access on the thread the object belongs to.
@@ -577,11 +597,7 @@ class HasThreadEditAccess(IsAuthenticated):
else:
thread = obj
return models.ThreadAccess.objects.filter(
thread=thread,
mailbox__accesses__user=request.user,
role__in=enums.THREAD_ROLES_CAN_EDIT,
).exists()
return thread.get_abilities(request.user)[enums.ThreadAbilities.CAN_EDIT]
class HasAccessToMailbox(IsAuthenticated):

View File

@@ -651,6 +651,30 @@ class ThreadSerializer(serializers.ModelSerializer):
labels = serializers.SerializerMethodField()
summary = serializers.CharField(read_only=True)
events_count = serializers.IntegerField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
@extend_schema_field(serializers.DictField(child=serializers.BooleanField()))
def get_abilities(self, instance):
"""Return the current user's abilities on the thread, scoped to
the mailbox context when provided. Frontend components use this
to hide mutating actions (archive, spam, delete, reply...) from
users with read-only access.
Prefers the ``_can_edit`` annotation set by the viewset queryset
(single SQL subquery for the whole page) and falls back to a
per-instance query for code paths that build threads outside the
annotated queryset (e.g. the split action).
"""
can_edit = getattr(instance, "_can_edit", None)
if can_edit is not None:
return {enums.ThreadAbilities.CAN_EDIT: can_edit}
request = self.context.get("request")
if request is None:
return {}
return instance.get_abilities(
request.user, mailbox_id=self.context.get("mailbox_id")
)
@extend_schema_field(serializers.BooleanField())
def get_has_unread(self, instance):
@@ -750,6 +774,7 @@ class ThreadSerializer(serializers.ModelSerializer):
"labels",
"summary",
"events_count",
"abilities",
]
read_only_fields = fields # Mark all as read-only for safety

View File

@@ -13,7 +13,7 @@ from drf_spectacular.utils import (
from rest_framework import serializers as drf_serializers
from rest_framework.views import APIView
from core import enums, models
from core import models
from core.services.search.tasks import update_threads_mailbox_flags_task
from .. import permissions
@@ -188,15 +188,24 @@ class ChangeFlagView(APIView):
current_time = timezone.now()
updated_threads = set()
# Get IDs of threads the user has access to
accessible_thread_ids_qs = models.ThreadAccess.objects.filter(
mailbox__accesses__user=request.user,
).values_list("thread_id", flat=True)
# Unread and starred are personal actions that don't require EDITOR access.
if flag not in ("unread", "starred"):
accessible_thread_ids_qs = accessible_thread_ids_qs.filter(
role__in=enums.THREAD_ROLES_CAN_EDIT
# Get IDs of threads the user has access to.
#
# Unread and starred are personal actions: they only mutate the
# user's own ThreadAccess row (read_at / starred_at) and are
# intentionally allowed for viewers. Any MailboxAccess is enough.
#
# All other flags (trashed / archived / spam) mutate shared
# Message state, so they require full edit rights on the thread:
# EDITOR ThreadAccess role AND CAN_EDIT MailboxAccess role. We
# delegate to ThreadAccess.objects.editable_by to keep the
# permission check consistent across the codebase.
if flag in ("unread", "starred"):
accessible_thread_ids_qs = models.ThreadAccess.objects.filter(
mailbox__accesses__user=request.user,
)
else:
accessible_thread_ids_qs = models.ThreadAccess.objects.editable_by(
request.user
)
if mailbox_id:
@@ -204,6 +213,10 @@ class ChangeFlagView(APIView):
mailbox_id=mailbox_id
)
accessible_thread_ids_qs = accessible_thread_ids_qs.values_list(
"thread_id", flat=True
)
if flag in ("unread", "starred") and not thread_ids and message_ids:
# If no thread_ids but we have message_ids, we need to get the thread_ids from the messages
thread_ids = (

View File

@@ -291,12 +291,22 @@ class LabelViewSet(
{"detail": "No thread IDs provided"},
status=status.HTTP_400_BAD_REQUEST,
)
# Require full edit rights on each thread **scoped to the label's
# own mailbox**. The mailbox here is intrinsic to the label (a
# property of the resource), not ambient from the caller's view,
# so we always pass `label.mailbox_id`. This enforces two things
# at once:
# - User must have full edit rights (EDITOR ThreadAccess role +
# CAN_EDIT MailboxAccess role) on the thread — blocks viewer
# access to the thread.
# - The thread must actually exist under label.mailbox — blocks
# attaching a label to a thread that is only visible from a
# different mailbox, even when the user has edit rights there.
accessible_threads = models.Thread.objects.filter(
Exists(
models.ThreadAccess.objects.filter(
mailbox__accesses__user=request.user,
thread=OuterRef("pk"),
)
models.ThreadAccess.objects.editable_by(
request.user, mailbox_id=label.mailbox_id
).filter(thread=OuterRef("pk"))
),
id__in=thread_ids,
)
@@ -350,12 +360,16 @@ class LabelViewSet(
{"detail": "No thread IDs provided"},
status=status.HTTP_400_BAD_REQUEST,
)
# Removing a label from a thread is a mutation on shared state,
# so it requires full edit rights (EDITOR ThreadAccess role +
# CAN_EDIT MailboxAccess role) scoped to the label's own mailbox
# — same check as add_threads. See the comment there for the
# rationale on using `label.mailbox_id` (intrinsic to the label).
accessible_threads = models.Thread.objects.filter(
Exists(
models.ThreadAccess.objects.filter(
mailbox__accesses__user=request.user,
thread=OuterRef("pk"),
)
models.ThreadAccess.objects.editable_by(
request.user, mailbox_id=label.mailbox_id
).filter(thread=OuterRef("pk"))
),
id__in=thread_ids,
)

View File

@@ -38,8 +38,12 @@ class ThreadViewSet(
lookup_url_kwarg = "pk"
def get_permissions(self):
"""Use HasThreadEditAccess for actions that require EDITOR role."""
if self.action in ("destroy", "split"):
"""Use HasThreadEditAccess for actions that require EDITOR role.
`refresh_summary` mutates thread state (writes `thread.summary`)
so it must also gate on full edit rights, not just authentication.
"""
if self.action in ("destroy", "split", "refresh_summary"):
return [permissions.HasThreadEditAccess()]
return super().get_permissions()
@@ -143,6 +147,15 @@ class ThreadViewSet(
both code paths always expose the same fields (e.g. ``events_count``),
avoiding silent divergence in the serialized payload.
"""
can_edit_qs = models.ThreadAccess.objects.filter(
thread=OuterRef("pk"),
role=enums.ThreadAccessRoleChoices.EDITOR,
mailbox__accesses__user=user,
mailbox__accesses__role__in=enums.MAILBOX_ROLES_CAN_EDIT,
)
if mailbox_id:
can_edit_qs = can_edit_qs.filter(mailbox_id=mailbox_id)
return queryset.annotate(
_has_unread=models.ThreadAccess.thread_unread_filter(user, mailbox_id),
_has_starred=models.ThreadAccess.thread_starred_filter(user, mailbox_id),
@@ -162,6 +175,7 @@ class ThreadViewSet(
)
),
events_count=Count("events", distinct=True),
_can_edit=Exists(can_edit_qs),
)
@staticmethod
@@ -867,7 +881,11 @@ class ThreadViewSet(
)
serializer = serializers.ThreadSerializer(
new_thread, context={"request": request}
new_thread,
context={
"request": request,
"mailbox_id": request.query_params.get("mailbox_id"),
},
)
return drf.response.Response(serializer.data, status=status.HTTP_201_CREATED)

View File

@@ -1,13 +1,16 @@
"""API ViewSet for ThreadAccess model."""
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
extend_schema,
)
from rest_framework import mixins, viewsets
from rest_framework.exceptions import ValidationError
from core import models
from core import enums, models
from .. import permissions, serializers
@@ -62,3 +65,22 @@ class ThreadAccessViewSet(
"""Create a new thread access."""
request.data["thread"] = self.kwargs.get("thread_id")
return super().create(request, *args, **kwargs)
@transaction.atomic
def perform_destroy(self, instance):
"""Prevent deletion of the last editor access on a thread."""
if instance.role == enums.ThreadAccessRoleChoices.EDITOR:
remaining_editors = (
models.ThreadAccess.objects.select_for_update()
.filter(
thread=instance.thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
.exclude(id=instance.id)
.exists()
)
if not remaining_editors:
raise ValidationError(
"Cannot delete the last editor access of a thread."
)
super().perform_destroy(instance)

View File

@@ -139,6 +139,12 @@ class MailboxAbilities(models.TextChoices):
CAN_IMPORT_MESSAGES = "import_messages", "Can import messages"
class ThreadAbilities(models.TextChoices):
"""Defines specific abilities a user can have on a Thread."""
CAN_EDIT = "edit", "Can edit the thread (archive, spam, trash, labels, events)"
class ThreadEventTypeChoices(models.TextChoices):
"""Defines the possible types of thread events."""

View File

@@ -34,6 +34,7 @@ from encrypted_fields.fields import EncryptedJSONField, EncryptedTextField
from timezone_field import TimeZoneField
from core.enums import (
MAILBOX_ROLES_CAN_EDIT,
ChannelScopeLevel,
CompressionTypeChoices,
CRUDAbilities,
@@ -45,6 +46,7 @@ from core.enums import (
MessageDeliveryStatusChoices,
MessageRecipientTypeChoices,
MessageTemplateTypeChoices,
ThreadAbilities,
ThreadAccessRoleChoices,
ThreadEventTypeChoices,
UserAbilities,
@@ -1221,6 +1223,25 @@ class Thread(BaseModel):
]
)
def get_abilities(self, user, mailbox_id=None):
"""
Compute and return abilities for a given user on this thread.
Scoped to an optional mailbox context: when provided, the check
is restricted to the ThreadAccess of that mailbox. Otherwise the
check looks across any of the user's mailboxes.
"""
if not user.is_authenticated:
return {ThreadAbilities.CAN_EDIT: False}
can_edit = (
ThreadAccess.objects.editable_by(user, mailbox_id=mailbox_id)
.filter(thread=self)
.exists()
)
return {ThreadAbilities.CAN_EDIT: can_edit}
class Label(BaseModel):
"""Label model to organize threads into folders using slash-based naming."""
@@ -1425,6 +1446,41 @@ class Label(BaseModel):
super().delete(*args, **kwargs)
class ThreadAccessQuerySet(models.QuerySet):
"""Custom queryset exposing reusable access-scoped filters."""
def editable_by(self, user, mailbox_id=None):
"""Return ThreadAccess rows granting full edit rights to `user`.
Combines BOTH conditions, which are the single source of truth
for "can this user edit this thread?":
- `ThreadAccess.role == EDITOR`
- The user's `MailboxAccess.role` on that mailbox is in
`MAILBOX_ROLES_CAN_EDIT`
When `mailbox_id` is provided, the result is also scoped to that
mailbox. Used by the flag, label, thread and thread-event
endpoints to replace ad-hoc permission checks.
IMPORTANT: the two `mailbox__accesses__*` conditions MUST stay
inside the same `.filter()` call. If split, Django generates two
independent JOINs and the query matches any pair of mailbox
accesses satisfying either condition — a false positive.
"""
qs = self.filter(
role=ThreadAccessRoleChoices.EDITOR,
mailbox__accesses__user=user,
mailbox__accesses__role__in=MAILBOX_ROLES_CAN_EDIT,
)
if mailbox_id is not None:
qs = qs.filter(mailbox_id=mailbox_id)
return qs
ThreadAccessManager = models.Manager.from_queryset(ThreadAccessQuerySet)
class ThreadAccess(BaseModel):
"""Thread access model to store thread access information for a mailbox."""
@@ -1442,6 +1498,8 @@ class ThreadAccess(BaseModel):
read_at = models.DateTimeField("read at", null=True, blank=True)
starred_at = models.DateTimeField("starred at", null=True, blank=True)
objects = ThreadAccessManager()
class Meta:
db_table = "messages_threadaccess"
verbose_name = "thread access"

View File

@@ -549,10 +549,16 @@ class TestLabelViewSet:
models.MailboxRoleChoices.SENDER,
],
)
def test_add_threads_to_label(self, api_client, label, role):
def test_add_threads_to_label(self, api_client, user, role):
"""Test adding threads to a label."""
# The label and the threads must live in the same mailbox:
# that's the real flow (a user manages a label from inside the
# mailbox that owns it). Parametrising the role on that same
# mailbox also matches production usage, where any role in
# MAILBOX_ROLES_CAN_EDIT can manage labels.
mailbox = MailboxFactory()
mailbox.accesses.create(user=label.mailbox.accesses.first().user, role=role)
mailbox.accesses.create(user=user, role=role)
label = LabelFactory(mailbox=mailbox)
threads = ThreadFactory.create_batch(3)
for thread in threads:
thread.accesses.create(
@@ -616,12 +622,14 @@ class TestLabelViewSet:
models.MailboxRoleChoices.SENDER,
],
)
def test_remove_threads_from_label(self, api_client, label, mailbox_role):
def test_remove_threads_from_label(self, api_client, user, mailbox_role):
"""Test removing threads from a label."""
# Mirror of test_add_threads_to_label — see that test for the
# rationale on putting the label and the threads in the same
# mailbox where the role is granted.
mailbox = MailboxFactory()
mailbox.accesses.create(
user=label.mailbox.accesses.first().user, role=mailbox_role
)
mailbox.accesses.create(user=user, role=mailbox_role)
label = LabelFactory(mailbox=mailbox)
threads = ThreadFactory.create_batch(3)
for thread in threads:
thread.accesses.create(
@@ -654,6 +662,98 @@ class TestLabelViewSet:
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 1 # Thread not removed
def test_add_threads_cross_mailbox_viewer_thread_access_forbidden(
self, api_client, label, user
):
"""User is EDITOR/ADMIN on label.mailbox (can manage labels) but
only has VIEWER ThreadAccess to the thread (via a different
mailbox). Labelling the thread is a shared-state mutation on the
thread, so it must be blocked.
"""
other_mailbox = MailboxFactory()
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER)
thread = ThreadFactory()
thread.accesses.create(
mailbox=other_mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR, # Mailbox has edit access
)
url = reverse("labels-add-threads", args=[label.pk])
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
# The endpoint returns 200 but silently skips the thread.
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 0
def test_remove_threads_cross_mailbox_viewer_thread_access_forbidden(
self, api_client, label, user
):
"""Mirror of add_threads above — removing a label from a thread
also requires full edit rights on the thread.
"""
other_mailbox = MailboxFactory()
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER)
thread = ThreadFactory()
thread.accesses.create(
mailbox=other_mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
label.threads.add(thread) # Pre-existing association
url = reverse("labels-remove-threads", args=[label.pk])
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 1 # Not removed
def test_add_threads_thread_not_in_label_mailbox_forbidden(
self, api_client, label, user
):
"""User is EDITOR on both label.mailbox and another mailbox, but
the thread is only accessible from the other mailbox. Attaching
a label from label.mailbox to a thread that is not visible from
that mailbox would leak the label outside its owning mailbox,
so it must be blocked even though the user technically has full
edit rights on the thread elsewhere.
"""
other_mailbox = MailboxFactory()
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.EDITOR)
thread = ThreadFactory()
thread.accesses.create(
mailbox=other_mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
url = reverse("labels-add-threads", args=[label.pk])
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
# Endpoint returns 200 but silently skips the out-of-mailbox thread.
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 0
def test_remove_threads_thread_not_in_label_mailbox_forbidden(
self, api_client, label, user
):
"""Mirror of add_threads above — removing a label from a thread
must also be scoped to the label's own mailbox, so a pre-existing
(inconsistent) association to an out-of-mailbox thread cannot be
unwound via the endpoint either.
"""
other_mailbox = MailboxFactory()
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.EDITOR)
thread = ThreadFactory()
thread.accesses.create(
mailbox=other_mailbox,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
label.threads.add(thread) # Pre-existing (inconsistent) association
url = reverse("labels-remove-threads", args=[label.pk])
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
assert response.status_code == status.HTTP_200_OK
assert label.threads.count() == 1 # Not removed
def test_label_hierarchy(self, mailbox):
"""Test label hierarchy with slash-based naming."""
parent_label = LabelFactory(name="Work", mailbox=mailbox)

View File

@@ -1,6 +1,6 @@
"""Test changing flags on messages or threads."""
# pylint: disable=redefined-outer-name
# pylint: disable=redefined-outer-name,too-many-lines
import json
from datetime import timedelta
@@ -12,7 +12,7 @@ from django.utils import timezone
import pytest
from rest_framework import status
from core import enums
from core import enums, models
from core.factories import (
MailboxFactory,
MessageFactory,
@@ -677,6 +677,49 @@ def test_api_flag_viewer_can_star_thread(api_client):
assert access.starred_at is not None
@pytest.mark.parametrize(
"flag,field",
[
("trashed", "is_trashed"),
("archived", "is_archived"),
("spam", "is_spam"),
],
)
def test_api_flag_viewer_mailbox_with_editor_thread_access_forbidden(
api_client, flag, field
):
"""A user with VIEWER MailboxAccess cannot mutate shared-state flags
via message_ids even when the mailbox has EDITOR ThreadAccess on the thread.
"""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user]) # VIEWER mailbox role
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR, # Mailbox has edit on thread
)
message = MessageFactory(thread=thread)
data = {"flag": flag, "value": True, "message_ids": [str(message.id)]}
response = api_client.post(API_URL, data=data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["updated_threads"] == 0
message.refresh_from_db()
assert getattr(message, field) is False
# Elevating MailboxAccess to EDITOR lets the same request succeed.
models.MailboxAccess.objects.filter(user=user, mailbox=mailbox).update(
role=enums.MailboxRoleChoices.EDITOR
)
response = api_client.post(API_URL, data=data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["updated_threads"] == 1
# --- Tests for Trashed Flag ---
@@ -684,7 +727,7 @@ def test_api_flag_mark_messages_trashed_success(api_client):
"""Test marking messages as trashed successfully."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -717,7 +760,7 @@ def test_api_flag_mark_messages_untrashed_success(api_client):
"""Test marking messages as untrashed successfully."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -753,7 +796,7 @@ def test_api_flag_mark_messages_archived_success(api_client):
"""Test marking messages as archived successfully."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -786,7 +829,7 @@ def test_api_flag_mark_messages_unarchived_success(api_client):
"""Test marking messages as unarchived successfully."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -820,7 +863,7 @@ def test_api_flag_mark_messages_spam_success(api_client):
"""Test marking messages as spam successfully."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -853,7 +896,7 @@ def test_api_flag_mark_messages_not_spam_success(api_client):
"""Test marking messages as not spam successfully."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -896,7 +939,7 @@ def test_api_flag_cascade_to_draft_children(api_client, flag, field, date_field)
"""Flagging a message by message_id cascades to its draft children."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -932,7 +975,7 @@ def test_api_flag_cascade_unflag_to_draft_children(api_client, flag, field, date
"""Unflagging a message by message_id cascades to its draft children."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
@@ -965,7 +1008,7 @@ def test_api_flag_cascade_does_not_affect_non_draft_children(api_client):
"""Trashing a message does NOT cascade to non-draft children."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user])
mailbox = MailboxFactory(users_admin=[user])
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR

View File

@@ -1,11 +1,14 @@
"""Tests for the ThreadAccess API endpoints."""
import threading
import uuid
from django.db import connection
from django.urls import reverse
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import enums, factories, models
@@ -572,6 +575,10 @@ class TestThreadAccessDelete:
thread=thread,
role=thread_access_role,
)
# Ensure another editor exists so the last-editor guard doesn't block
factories.ThreadAccessFactory(
thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
)
api_client.force_authenticate(user=user)
url = get_thread_access_url(thread.id, thread_access.id)
@@ -584,20 +591,22 @@ class TestThreadAccessDelete:
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
# A user with viewer rights on the thread but edit-level rights on
# the mailbox can still leave: destroying the shared ThreadAccess
# affects the whole mailbox, so mailbox authority is what matters.
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.SENDER),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
],
)
def test_delete_thread_access_forbidden(
def test_delete_thread_access_self_removal(
self, api_client, thread_access_role, mailbox_access_role
):
"""Test deleting a thread access without permission."""
"""A user with edit-level rights on the mailbox can remove its own
ThreadAccess regardless of the thread role."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
# Create a thread access that the user doesn't have any role to delete
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
@@ -610,14 +619,93 @@ class TestThreadAccessDelete:
thread=thread,
role=thread_access_role,
)
# Ensure another editor exists so the last-editor guard doesn't block
factories.ThreadAccessFactory(
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
url = get_thread_access_url(thread.id, thread_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.ThreadAccess.objects.filter(id=thread_access.id).exists()
@pytest.mark.parametrize(
"thread_access_role",
[
enums.ThreadAccessRoleChoices.VIEWER,
enums.ThreadAccessRoleChoices.EDITOR,
],
)
def test_delete_thread_access_mailbox_viewer_forbidden(
self, api_client, thread_access_role
):
"""A mailbox viewer cannot destroy the mailbox's ThreadAccess — that
would revoke the thread for every other member of the mailbox."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.VIEWER,
)
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
factories.ThreadAccessFactory(
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
url = get_thread_access_url(thread.id, thread_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.ThreadAccess.objects.filter(id=thread_access.id).exists()
# Create a thread access that the user doesn't have any role to delete
thread_access = factories.ThreadAccessFactory()
url = get_thread_access_url(thread.id, thread_access.id)
@pytest.mark.parametrize(
"thread_access_role, mailbox_access_role",
[
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.SENDER),
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
],
)
def test_delete_thread_access_other_forbidden(
self, api_client, thread_access_role, mailbox_access_role
):
"""A user without full edit rights cannot delete another user's thread access."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=mailbox_access_role,
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=thread_access_role,
)
# Another user's thread access on the same thread
other_thread_access = factories.ThreadAccessFactory(thread=thread)
url = get_thread_access_url(thread.id, other_thread_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.ThreadAccess.objects.filter(id=other_thread_access.id).exists()
# Non-existent thread access
url = get_thread_access_url(thread.id, uuid.uuid4())
response = api_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -631,9 +719,150 @@ class TestThreadAccessDelete:
response = api_client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_thread_access_last_editor_rejected(self, api_client):
"""Deleting the last editor access on a thread must be rejected."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
api_client.force_authenticate(user=user)
url = get_thread_access_url(thread.id, thread_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.ThreadAccess.objects.filter(id=thread_access.id).exists()
def test_delete_thread_access_last_editor_with_viewers_rejected(self, api_client):
"""Deleting the last editor is rejected even if viewers remain."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
thread = factories.ThreadFactory()
editor_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
# Add viewers — they don't count as editors
factories.ThreadAccessFactory(
thread=thread, role=enums.ThreadAccessRoleChoices.VIEWER
)
api_client.force_authenticate(user=user)
url = get_thread_access_url(thread.id, editor_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.ThreadAccess.objects.filter(id=editor_access.id).exists()
def test_delete_thread_access_editor_allowed_when_others_remain(self, api_client):
"""Deleting an editor access is allowed when other editors remain."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
thread = factories.ThreadFactory()
editor_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
# Another editor on the thread
factories.ThreadAccessFactory(
thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
)
api_client.force_authenticate(user=user)
url = get_thread_access_url(thread.id, editor_access.id)
response = api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.ThreadAccess.objects.filter(id=editor_access.id).exists()
def test_delete_thread_access_unauthorized(self, api_client):
"""Test deleting a thread access without authentication."""
thread = factories.ThreadFactory()
url = get_thread_access_url(thread.id, uuid.uuid4())
response = api_client.delete(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.django_db(transaction=True)
def test_delete_thread_access_concurrent_last_editors(self):
"""Two concurrent deletes of the last two editors must not orphan the thread.
Without select_for_update, both requests can see the other editor still
present and both proceed, leaving zero editors.
"""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
thread = factories.ThreadFactory()
editor_a = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
other_mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=other_mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
editor_b = factories.ThreadAccessFactory(
mailbox=other_mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
url_a = get_thread_access_url(thread.id, editor_a.id)
url_b = get_thread_access_url(thread.id, editor_b.id)
results = {}
def delete_access(name, url):
try:
client = APIClient()
client.force_authenticate(user=user)
results[name] = client.delete(url)
finally:
connection.close()
t1 = threading.Thread(target=delete_access, args=("a", url_a))
t2 = threading.Thread(target=delete_access, args=("b", url_b))
t1.start()
t2.start()
t1.join(timeout=10)
t2.join(timeout=10)
status_codes = {results["a"].status_code, results["b"].status_code}
# One must succeed (204), the other must be rejected (400)
assert status_codes == {
status.HTTP_204_NO_CONTENT,
status.HTTP_400_BAD_REQUEST,
}, f"Expected one 204 and one 400, got {status_codes}"
# At least one editor must remain
remaining = models.ThreadAccess.objects.filter(
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
).count()
assert remaining == 1

View File

@@ -305,6 +305,33 @@ class TestViewerCannotCreateEvents:
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
assert response.status_code == status.HTTP_201_CREATED
def test_viewer_mailbox_with_editor_thread_access_cannot_create_event(
self, api_client
):
"""Previously-missed scenario: a user with only VIEWER MailboxAccess
cannot post events even when the mailbox has EDITOR ThreadAccess.
Creating a ThreadEvent mutates shared state, so both role checks
must pass.
"""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.VIEWER, # VIEWER mailbox role
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
api_client.force_authenticate(user=user)
data = {"type": "im", "data": {"content": "from a mailbox viewer"}}
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestParameterConfusionAttack:
"""Test that conflicting thread_id in URL path vs query params can't bypass permissions."""

View File

@@ -130,6 +130,28 @@ def test_split_thread_feature_disabled(api_client):
assert Thread.objects.filter(id=thread.id).count() == 1
def test_split_thread_viewer_mailbox_with_editor_thread_access_forbidden(api_client):
"""Previously-missed scenario: a user with VIEWER MailboxAccess on a
shared inbox cannot split a thread even when the mailbox itself has
EDITOR ThreadAccess on the thread — the user's own mailbox role must
be honoured.
"""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user]) # VIEWER mailbox role
thread, messages = _create_thread_with_messages(mailbox, count=3)
ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
url = _get_split_url(thread.id)
response = api_client.post(url, {"message_id": str(messages[1].id)})
assert response.status_code == status.HTTP_403_FORBIDDEN
# --- Validation tests ---

View File

@@ -107,9 +107,14 @@ def test_api_flag_thread_viewer_should_not_update(api_client):
assert thread.has_trashed is False
assert thread.messages.first().is_trashed is False
# Elevate to EDITOR and verify flag change succeeds
# Elevate BOTH ThreadAccess and MailboxAccess to an edit role. Edit
# rights require both — a VIEWER mailbox role on a shared inbox
# cannot mutate thread state even when ThreadAccess is EDITOR.
thread_access.role = enums.ThreadAccessRoleChoices.EDITOR
thread_access.save()
models.MailboxAccess.objects.filter(user=user, mailbox=mailbox).update(
role=enums.MailboxRoleChoices.EDITOR
)
response = api_client.post(FLAG_API_URL, data=data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["updated_threads"] == 1
@@ -124,7 +129,7 @@ def test_api_flag_trash_single_thread_success(api_client):
"""Test marking a single thread as trashed successfully via flag endpoint."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user])
mailbox = factories.MailboxFactory(users_admin=[user])
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
@@ -164,7 +169,7 @@ def test_api_flag_untrash_single_thread_success(api_client):
"""Test marking a single thread as untrashed successfully via flag endpoint."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user])
mailbox = factories.MailboxFactory(users_admin=[user])
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
@@ -208,7 +213,7 @@ def test_api_flag_trash_multiple_threads_success(api_client):
"""Test marking multiple threads as trashed successfully."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user])
mailbox = factories.MailboxFactory(users_admin=[user])
thread1 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
@@ -275,7 +280,7 @@ def test_api_flag_spam_single_thread_success(api_client):
"""Test marking a single thread as spam successfully via flag endpoint."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user])
mailbox = factories.MailboxFactory(users_admin=[user])
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
@@ -313,7 +318,7 @@ def test_api_flag_not_spam_single_thread_success(api_client):
"""Test marking a single thread as not spam successfully via flag endpoint."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user])
mailbox = factories.MailboxFactory(users_admin=[user])
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
@@ -350,7 +355,7 @@ def test_api_flag_spam_multiple_threads_success(api_client):
"""Test marking multiple threads as spam successfully."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user])
mailbox = factories.MailboxFactory(users_admin=[user])
thread1 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
@@ -406,3 +411,97 @@ def test_api_flag_spam_multiple_threads_success(api_client):
assert thread2.messages.first().is_spam is True
msg3.refresh_from_db()
assert msg3.is_spam is True # Remained spam
# --- Tests for VIEWER MailboxAccess + EDITOR ThreadAccess combination ---
#
# These tests cover the previously-missed scenario where a user is a VIEWER
# on a shared mailbox that has EDITOR ThreadAccess to threads that land in
# it. The pre-fix code checked only the ThreadAccess role, so a mailbox
# viewer could mutate threads despite having no edit right on the mailbox.
@pytest.mark.parametrize(
"flag,field",
[
("archived", "is_archived"),
("spam", "is_spam"),
("trashed", "is_trashed"),
],
)
def test_api_flag_viewer_mailbox_with_editor_thread_access_forbidden(
api_client, flag, field
):
"""A user with VIEWER MailboxAccess cannot mutate shared-state flags
even when the mailbox itself has EDITOR ThreadAccess on the thread.
"""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user]) # VIEWER mailbox role
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR, # Mailbox has edit on thread
)
factories.MessageFactory(thread=thread)
data = {"flag": flag, "value": True, "thread_ids": [str(thread.id)]}
response = api_client.post(FLAG_API_URL, data=data, format="json")
# The user should not be able to mutate the flag — 200 + 0 updates.
assert response.status_code == status.HTTP_200_OK
assert response.data["updated_threads"] == 0
thread.refresh_from_db()
message = thread.messages.first()
assert getattr(message, field) is False
# Elevating MailboxAccess to EDITOR lets the same request succeed.
models.MailboxAccess.objects.filter(user=user, mailbox=mailbox).update(
role=enums.MailboxRoleChoices.EDITOR
)
response = api_client.post(FLAG_API_URL, data=data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["updated_threads"] == 1
def test_api_flag_viewer_mailbox_can_still_star_and_mark_unread(api_client):
"""Regression guard: star and unread are personal-state actions and
remain available to viewers (both mailbox-viewer and thread-viewer).
"""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
mailbox = factories.MailboxFactory(users_read=[user]) # VIEWER mailbox role
thread = factories.ThreadFactory()
thread_access = factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.VIEWER, # VIEWER thread role too
)
factories.MessageFactory(thread=thread)
starred_data = {
"flag": "starred",
"value": True,
"thread_ids": [str(thread.id)],
"mailbox_id": str(mailbox.id),
}
response = api_client.post(FLAG_API_URL, data=starred_data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["updated_threads"] == 1
thread_access.refresh_from_db()
assert thread_access.starred_at is not None
unread_data = {
"flag": "unread",
"value": True,
"thread_ids": [str(thread.id)],
"mailbox_id": str(mailbox.id),
"read_at": None,
}
response = api_client.post(FLAG_API_URL, data=unread_data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["updated_threads"] == 1
thread_access.refresh_from_db()
assert thread_access.read_at is None

View File

@@ -254,14 +254,75 @@ def test_delete_thread_viewer_should_be_forbidden(api_client):
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Thread.objects.filter(pk=thread.pk).exists()
# Elevate to EDITOR and verify delete succeeds
# Elevate BOTH ThreadAccess and MailboxAccess to EDITOR — full edit
# rights on a thread require both roles.
thread_access.role = enums.ThreadAccessRoleChoices.EDITOR
thread_access.save()
MailboxAccess.objects.filter(user=user, mailbox=mailbox).update(
role=enums.MailboxRoleChoices.EDITOR
)
response = api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Thread.objects.filter(pk=thread.pk).exists()
def test_delete_thread_viewer_mailbox_with_editor_thread_access_forbidden(api_client):
"""Previously-missed scenario: VIEWER MailboxAccess + EDITOR ThreadAccess.
A shared inbox grants EDITOR ThreadAccess to every incoming thread,
but a user with only VIEWER MailboxAccess on that inbox must not be
able to delete those threads. `HasThreadEditAccess` previously
checked only the ThreadAccess role and missed this case.
"""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user]) # VIEWER mailbox role
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR, # Mailbox has edit on thread
)
MessageFactory(thread=thread)
url = reverse("threads-detail", kwargs={"pk": str(thread.id)})
response = api_client.delete(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Thread.objects.filter(pk=thread.pk).exists()
# Elevating MailboxAccess to EDITOR unblocks the destroy action.
MailboxAccess.objects.filter(user=user, mailbox=mailbox).update(
role=enums.MailboxRoleChoices.EDITOR
)
response = api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Thread.objects.filter(pk=thread.pk).exists()
def test_refresh_summary_viewer_forbidden(api_client):
"""A viewer cannot trigger the AI summary regeneration — it mutates
`thread.summary`. The endpoint is covered by `HasThreadEditAccess`
via `ThreadViewSet.get_permissions`.
"""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory(users_read=[user]) # VIEWER mailbox role
thread = ThreadFactory(summary="initial")
ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
MessageFactory(thread=thread)
url = reverse("threads-refresh-summary", kwargs={"pk": str(thread.id)})
response = api_client.post(url)
assert response.status_code == status.HTTP_403_FORBIDDEN
thread.refresh_from_db()
assert thread.summary == "initial" # untouched
def test_list_threads_success(api_client):
"""Test listing threads successfully."""
user = UserFactory()

View File

@@ -407,7 +407,7 @@ class Command(BaseCommand):
models.ThreadAccess.objects.create(
thread=thread,
mailbox=mailbox,
role=ThreadAccessRoleChoices.VIEWER,
role=ThreadAccessRoleChoices.EDITOR,
)
models.Message.objects.create(
thread=thread,

View File

@@ -215,18 +215,18 @@ test.describe("Thread read / unread", () => {
await page
.getByRole("heading", { name: "Inbox thread alpha", level: 2 })
.waitFor({ state: "visible" });
await page.waitForLoadState("networkidle");
// Close the thread view to go back to the list
await page.getByRole("button", { name: "Close this thread" }).click();
// Verify the thread is now read (no unread indicator)
// Wait for auto-read to propagate: the sidebar reflects the read state
// once the flag mutation succeeds and the thread list cache is updated.
await expect(
page.locator('[data-unread="false"]', {
hasText: "Inbox thread alpha",
}),
).toBeVisible();
// Close the thread view to go back to the list
await page.getByRole("button", { name: "Close this thread" }).click();
// Click the filter button to apply unread filter (default selected filter)
await page.getByRole("button", { name: "Filter threads" }).click();
await page.waitForLoadState("networkidle");

View File

@@ -31,6 +31,22 @@
"{{count}} months ago_other": "{{count}} months ago",
"{{count}} occurrences_one": "{{count}} occurrence",
"{{count}} occurrences_other": "{{count}} occurrences",
"{{count}} out of {{total}} messages are now starred._one": "{{count}} out of {{total}} message is now starred.",
"{{count}} out of {{total}} messages are now starred._other": "{{count}} out of {{total}} messages are now starred.",
"{{count}} out of {{total}} messages have been archived._one": "{{count}} out of {{total}} message has been archived.",
"{{count}} out of {{total}} messages have been archived._other": "{{count}} out of {{total}} messages have been archived.",
"{{count}} out of {{total}} messages have been deleted._one": "{{count}} out of {{total}} message has been deleted.",
"{{count}} out of {{total}} messages have been deleted._other": "{{count}} out of {{total}} messages have been deleted.",
"{{count}} out of {{total}} messages have been reported as spam._one": "{{count}} out of {{total}} message has been reported as spam.",
"{{count}} out of {{total}} messages have been reported as spam._other": "{{count}} out of {{total}} messages have been reported as spam.",
"{{count}} out of {{total}} threads are now starred._one": "{{count}} out of {{total}} thread is now starred.",
"{{count}} out of {{total}} threads are now starred._other": "{{count}} out of {{total}} threads are now starred.",
"{{count}} out of {{total}} threads have been archived._one": "{{count}} out of {{total}} thread has been archived.",
"{{count}} out of {{total}} threads have been archived._other": "{{count}} out of {{total}} threads have been archived.",
"{{count}} out of {{total}} threads have been deleted._one": "{{count}} out of {{total}} thread has been deleted.",
"{{count}} out of {{total}} threads have been deleted._other": "{{count}} out of {{total}} threads have been deleted.",
"{{count}} out of {{total}} threads have been reported as spam._one": "{{count}} out of {{total}} thread has been reported as spam.",
"{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} out of {{total}} threads have been reported as spam.",
"{{count}} results_one": "{{count}} result",
"{{count}} results_other": "{{count}} results",
"{{count}} results mentioning you_one": "{{count}} result mentioning you",
@@ -104,6 +120,7 @@
"Addresses": "Addresses",
"After creating the widget, you will receive the installation code to add to your website.": "After creating the widget, you will receive the installation code to add to your website.",
"All messages": "All messages",
"All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.",
"Always": "Always",
"An address with this prefix already exists in this domain.": "An address with this prefix already exists in this domain.",
"An archive is uploading": "An archive is uploading",
@@ -359,6 +376,8 @@
"Last saved {{relativeTime}}": "Last saved {{relativeTime}}",
"Last update: {{timestamp}}": "Last update: {{timestamp}}",
"Layout": "Layout",
"Leave this thread": "Leave this thread",
"Leave this thread?": "Leave this thread?",
"less than a minute ago": "less than a minute ago",
"Loading addresses...": "Loading addresses...",
"Loading auto-replies...": "Loading auto-replies...",
@@ -421,12 +440,20 @@
"No event found in calendar invite": "No event found in calendar invite",
"No integration found": "No integration found",
"No mailbox": "No mailbox",
"No message could be archived.": "No message could be archived.",
"No message could be deleted.": "No message could be deleted.",
"No message could be reported as spam.": "No message could be reported as spam.",
"No message could be starred.": "No message could be starred.",
"No results": "No results",
"No signature": "No signature",
"No signatures found": "No signatures found",
"No subject": "No subject",
"No summary available.": "No summary available.",
"No template found": "No template found",
"No thread could be archived.": "No thread could be archived.",
"No thread could be deleted.": "No thread could be deleted.",
"No thread could be reported as spam.": "No thread could be reported as spam.",
"No thread could be starred.": "No thread could be starred.",
"No threads": "No threads",
"No threads match the active filters": "No threads match the active filters",
"On going": "On going",
@@ -454,6 +481,7 @@
"Refresh": "Refresh",
"Refresh summary": "Refresh summary",
"Remove": "Remove",
"Remove access?": "Remove access?",
"Remove report": "Remove report",
"Remove spam report": "Remove spam report",
"Remove tag": "Remove tag",
@@ -632,6 +660,7 @@
"Yearly": "Yearly",
"Yesterday": "Yesterday",
"You": "You",
"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 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 can close this window and continue using the app.": "You can close this window and continue using the app.",
"You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.",
@@ -641,6 +670,8 @@
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients.",
"You have aborted the upload.": "You have aborted the upload.",
"You have unsaved changes. Are you sure you want to close?": "You have unsaved changes. Are you sure you want to close?",
"You left the thread": "You left the thread",
"You may not have sufficient permissions for all selected threads.": "You may not have sufficient permissions for all selected threads.",
"You must confirm this statement.": "You must confirm this statement.",
"Your email...": "Your email...",
"Your messages have been imported successfully!": "Your messages have been imported successfully!",

View File

@@ -47,6 +47,30 @@
"{{count}} occurrences_one": "{{count}} événement",
"{{count}} occurrences_many": "{{count}} événements",
"{{count}} occurrences_other": "{{count}} événements",
"{{count}} out of {{total}} messages are now starred._one": "{{count}} message sur {{total}} est maintenant suivi.",
"{{count}} out of {{total}} messages are now starred._many": "{{count}} messages sur {{total}} sont maintenant suivis.",
"{{count}} out of {{total}} messages are now starred._other": "{{count}} messages sur {{total}} sont maintenant suivis.",
"{{count}} out of {{total}} messages have been archived._one": "{{count}} message sur {{total}} a été archivé.",
"{{count}} out of {{total}} messages have been archived._many": "{{count}} messages sur {{total}} ont été archivés.",
"{{count}} out of {{total}} messages have been archived._other": "{{count}} messages sur {{total}} ont été archivés.",
"{{count}} out of {{total}} messages have been deleted._one": "{{count}} message sur {{total}} a été supprimé.",
"{{count}} out of {{total}} messages have been deleted._many": "{{count}} messages sur {{total}} ont été supprimés.",
"{{count}} out of {{total}} messages have been deleted._other": "{{count}} messages sur {{total}} ont été supprimés.",
"{{count}} out of {{total}} messages have been reported as spam._one": "{{count}} message sur {{total}} a été signalé comme spam.",
"{{count}} out of {{total}} messages have been reported as spam._many": "{{count}} messages sur {{total}} ont été signalés comme spam.",
"{{count}} out of {{total}} messages have been reported as spam._other": "{{count}} messages sur {{total}} ont été signalés comme spam.",
"{{count}} out of {{total}} threads are now starred._one": "{{count}} conversation sur {{total}} est maintenant suivie.",
"{{count}} out of {{total}} threads are now starred._many": "{{count}} conversations sur {{total}} sont maintenant suivies.",
"{{count}} out of {{total}} threads are now starred._other": "{{count}} conversations sur {{total}} sont maintenant suivies.",
"{{count}} out of {{total}} threads have been archived._one": "{{count}} conversation sur {{total}} a été archivée.",
"{{count}} out of {{total}} threads have been archived._many": "{{count}} conversations sur {{total}} ont été archivées.",
"{{count}} out of {{total}} threads have been archived._other": "{{count}} conversations sur {{total}} ont été archivées.",
"{{count}} out of {{total}} threads have been deleted._one": "{{count}} conversation sur {{total}} a été supprimée.",
"{{count}} out of {{total}} threads have been deleted._many": "{{count}} conversations sur {{total}} ont été supprimées.",
"{{count}} out of {{total}} threads have been deleted._other": "{{count}} conversations sur {{total}} ont été supprimées.",
"{{count}} out of {{total}} threads have been reported as spam._one": "{{count}} conversation sur {{total}} a été signalée comme spam.",
"{{count}} out of {{total}} threads have been reported as spam._many": "{{count}} conversations sur {{total}} ont été signalées comme spam.",
"{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} conversations sur {{total}} ont été signalées comme spam.",
"{{count}} results_one": "{{count}} résultat",
"{{count}} results_many": "{{count}} résultats",
"{{count}} results_other": "{{count}} résultats",
@@ -145,6 +169,7 @@
"Addresses": "Adresses",
"After creating the widget, you will receive the installation code to add to your website.": "Après avoir créé le widget, vous recevrez le code d'installation à ajouter à votre site web.",
"All messages": "Tous les messages",
"All users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "Tous les utilisateurs avec un accès à la boîte \"{{mailboxName}}\" ne verront plus ce thread.",
"Always": "Toujours",
"An address with this prefix already exists in this domain.": "Une adresse avec ce préfixe existe déjà dans ce domaine.",
"An archive is uploading": "Une archive est en cours de téléversement",
@@ -406,6 +431,8 @@
"Last saved {{relativeTime}}": "Dernière sauvegarde {{relativeTime}}",
"Last update: {{timestamp}}": "Dernière mise à jour : {{timestamp}}",
"Layout": "Mise en page",
"Leave this thread": "Quitter cette conversation",
"Leave this thread?": "Quitter cette conversation ?",
"less than a minute ago": "il y a moins d'une minute",
"Loading addresses...": "Chargement des adresses...",
"Loading auto-replies...": "Chargement des réponses automatiques...",
@@ -468,12 +495,20 @@
"No event found in calendar invite": "Aucun événement trouvé dans l'invitation calendrier",
"No integration found": "Aucune intégration trouvée",
"No mailbox": "Aucune boîte aux lettres",
"No message could be archived.": "Aucun message n'a pu être archivé.",
"No message could be deleted.": "Aucun message n'a pu être supprimé.",
"No message could be reported as spam.": "Aucun message n'a pu être signalé comme spam.",
"No message could be starred.": "Aucun message n'a pu être suivi.",
"No results": "Aucun résultat",
"No signature": "Aucune signature",
"No signatures found": "Aucune signature trouvée",
"No subject": "Aucun objet",
"No summary available.": "Aucun résumé disponible.",
"No template found": "Aucun modèle trouvé",
"No thread could be archived.": "Aucune conversation n'a pu être archivée.",
"No thread could be deleted.": "Aucune conversation n'a pu être supprimée.",
"No thread could be reported as spam.": "Aucune conversation n'a pu être signalée comme spam.",
"No thread could be starred.": "Aucune conversation n'a pu être suivie.",
"No threads": "Aucune conversation",
"No threads match the active filters": "Aucune conversation ne correspond aux filtres actifs",
"On going": "En cours",
@@ -501,6 +536,7 @@
"Refresh": "Actualiser",
"Refresh summary": "Actualiser le résumé",
"Remove": "Supprimer",
"Remove access?": "Retirer l'accès ?",
"Remove report": "Annuler le signalement",
"Remove spam report": "Annuler le signalement spam",
"Remove tag": "Supprimer le tag",
@@ -683,6 +719,7 @@
"Yearly": "Annuel",
"Yesterday": "Hier",
"You": "Vous",
"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 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 can close this window and continue using the app.": "Vous pouvez fermer cette fenêtre et continuer à utiliser l'application.",
"You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "Vous pouvez désormais prévenir la personne que sa boîte aux lettres est prête à être utilisée et lui communiquer les instructions pour s'authentifier.",
@@ -693,6 +730,8 @@
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.",
"You have aborted the upload.": "Vous avez annulé le téléversement.",
"You have unsaved changes. Are you sure you want to close?": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir fermer ?",
"You left the thread": "Vous avez quitté le fil",
"You may not have sufficient permissions for all selected threads.": "Vous n'avez peut-être pas les droits suffisants sur toutes les conversations sélectionnées.",
"You must confirm this statement.": "Vous devez confirmer cette déclaration.",
"Your email...": "Renseigner votre email...",
"Your messages have been imported successfully!": "Vos messages ont été importés avec succès !",

View File

@@ -137,6 +137,7 @@ export * from "./task_status_response";
export * from "./task_status_response_result";
export * from "./third_party_drive_retrieve_params";
export * from "./thread";
export * from "./thread_abilities";
export * from "./thread_access";
export * from "./thread_access_detail";
export * from "./thread_access_request";

View File

@@ -8,6 +8,7 @@
import type { ThreadAccessRoleChoices } from "./thread_access_role_choices";
import type { ThreadAccessDetail } from "./thread_access_detail";
import type { ThreadLabel } from "./thread_label";
import type { ThreadAbilities } from "./thread_abilities";
/**
* Serialize threads.
@@ -71,4 +72,5 @@ export interface Thread {
readonly labels: readonly ThreadLabel[];
readonly summary: string;
readonly events_count: number;
readonly abilities: ThreadAbilities;
}

View File

@@ -0,0 +1,9 @@
/**
* Generated by orval 🍺
* Do not edit manually.
* messages API
* This is the messages API schema.
* OpenAPI spec version: 1.0.0 (v1.0)
*/
export type ThreadAbilities = { [key: string]: boolean };

View File

@@ -49,6 +49,19 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const { activeFilters } = useThreadPanelFilters();
// Whether at least one selected thread has full edit rights — gates
// shared-state mutations (archive, spam, trash). The backend enforces
// per-thread permissions; the toast reports the actual updated count.
// Star and read/unread are personal state on the user's ThreadAccess
// and remain available regardless.
const canEditSelection = useMemo(() => {
if (selectedThreadIds.size === 0) return false;
return Array.from(selectedThreadIds).some((id) => {
const thread = threads?.results.find((t) => t.id === id);
return thread?.abilities?.edit === true;
});
}, [selectedThreadIds, threads?.results]);
const title = useMemo(() => {
if (searchParams.has('search')) return t('folder.search', { defaultValue: 'Search' });
if (searchParams.has('label_slug')) return (labelsQuery.data?.data || []).find((label) => label.slug === searchParams.get('label_slug'))?.name;
@@ -182,7 +195,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
aria-label={mainReadTooltip}
/>
</Tooltip>
{isSelectionMode && (
{isSelectionMode && canEditSelection && (
<>
<VerticalSeparator withPadding={false} />
{!isSpamView && !isTrashedView && !isDraftsView && (
@@ -225,28 +238,26 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
/>
</Tooltip>
)}
{
!isDraftsView && (
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
trashMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon name={trashIconName} type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
aria-label={trashLabel}
/>
</Tooltip>
)
}
{!isDraftsView && (
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
<Button
onClick={() => {
trashMutation({
threadIds: threadIdsToMark,
onSuccess: () => {
unselectThread();
onClearSelection();
}
});
}}
disabled={selectedThreadIds.size === 0}
icon={<Icon name={trashIconName} type={IconType.OUTLINED} />}
variant="tertiary"
size="nano"
aria-label={trashLabel}
/>
</Tooltip>
)}
<VerticalSeparator withPadding={false} />
</>
)}

View File

@@ -1,4 +1,4 @@
import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react"
import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react"
import { Icon, IconType, ShareModal } from "@gouvfr-lasuite/ui-kit"
import { useState } from "react";
import { ThreadAccessRoleChoices, ThreadAccessDetail, MailboxLight } from "@/features/api/gen/models";
@@ -28,6 +28,7 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) =>
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { selectedMailbox, selectedThread, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
const modals = useModals();
const { mutate: removeThreadAccess } = useThreadsAccessesDestroy({ mutation: { onSuccess: () => invalidateThreadMessages() } });
const { mutate: createThreadAccess } = useThreadsAccessesCreate({ mutation: { onSuccess: () => invalidateThreadMessages() } });
const { mutate: updateThreadAccess } = useThreadsAccessesUpdate({ mutation: { onSuccess: () => invalidateThreadMessages() } });
@@ -45,9 +46,13 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) =>
});
const searchResults = searchMailboxesQuery.data?.data.filter((mailbox) => !accesses.some(a => a.mailbox.id === mailbox.id)).map(getAccessUser) ?? [];
const normalizedAccesses = accesses.map((access) => ({ ...access, user: getAccessUser(access.mailbox) }));
const hasOnlyOneEditor = accesses.filter((a) => a.role === ThreadAccessRoleChoices.editor).length === 1;
const canManageThreadAccess = useAbility(Abilities.CAN_MANAGE_THREAD_ACCESS, [selectedMailbox!, selectedThread!]);
const normalizedAccesses = accesses.map((access) => ({
...access,
user: getAccessUser(access.mailbox),
can_delete: canManageThreadAccess && accesses.length > 1 && (!hasOnlyOneEditor || access.role !== ThreadAccessRoleChoices.editor),
}));
const handleCreateAccesses = (mailboxes: MailboxLight[], role: string) => {
const mailboxIds = [...new Set(mailboxes.map((m) => m.id))];
@@ -75,7 +80,7 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) =>
});
}
const handleDeleteAccess = (access: ThreadAccessDetail) => {
const handleDeleteAccess = async (access: ThreadAccessDetail) => {
// TODO : Update Share Modal to hide the remove button if there is only one editor
if (hasOnlyOneEditor && access.role === ThreadAccessRoleChoices.editor) {
addToast(<ToasterItem type="error">
@@ -87,6 +92,19 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) =>
return;
};
const isSelfRemoval = access.mailbox.id === selectedMailbox?.id;
const decision = await modals.deleteConfirmationModal({
title: isSelfRemoval ? t('Leave this thread?') : t('Remove access?'),
children: isSelfRemoval
? t(
'You and all users with access to the mailbox "{{mailboxName}}" will no longer see this thread.',
{ mailboxName: access.mailbox.email }
)
: t(
'All users with access to the mailbox "{{mailboxName}}" will no longer see this thread.',
{ mailboxName: access.mailbox.email }
),
});
if (decision !== 'delete') return;
removeThreadAccess({
id: access.id,
threadId: selectedThread!.id

View File

@@ -1,8 +1,9 @@
import { useMailboxContext } from "@/features/providers/mailbox";
import useRead from "@/features/message/use-read";
import useTrash from "@/features/message/use-trash";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { DropdownMenu, Icon, IconType, VerticalSeparator } from "@gouvfr-lasuite/ui-kit"
import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react"
import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react"
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ThreadAccessesWidget } from "../thread-accesses-widget";
@@ -10,6 +11,8 @@ import { ThreadLabelsWidget } from "../thread-labels-widget";
import useArchive from "@/features/message/use-archive";
import useSpam from "@/features/message/use-spam";
import useStarred from "@/features/message/use-starred";
import { MailboxRoleChoices, ThreadAccessRoleChoices, useThreadsAccessesDestroy } from "@/features/api/gen";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
type ThreadActionBarProps = {
canUndelete: boolean;
@@ -18,14 +21,50 @@ type ThreadActionBarProps = {
export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarProps) => {
const { t } = useTranslation();
const { selectedThread, unselectThread } = useMailboxContext();
const { selectedMailbox, selectedThread, unselectThread, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { markAsReadAt } = useRead();
const { markAsTrashed, markAsUntrashed } = useTrash();
const { markAsArchived, markAsUnarchived } = useArchive();
const { markAsSpam, markAsNotSpam } = useSpam();
const { markAsStarred, markAsUnstarred } = useStarred();
const { mutate: removeThreadAccess } = useThreadsAccessesDestroy();
const modals = useModals();
// Full edit rights on the thread — gates archive, spam, delete.
// Star and "mark as unread" remain visible because they are personal
// state on the user's ThreadAccess (read_at / starred_at).
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
const canShowArchiveCTA = canEditThread && !selectedThread?.is_spam
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const isStarred = selectedThread?.has_starred;
const mailboxAccess = selectedThread?.accesses.find((a) => a.mailbox.id === selectedMailbox?.id);
const hasOnlyOneEditor = selectedThread?.accesses.filter((a) => a.role === ThreadAccessRoleChoices.editor).length === 1;
const canLeaveThread = selectedMailbox?.role !== MailboxRoleChoices.viewer && mailboxAccess && selectedThread && (!hasOnlyOneEditor || mailboxAccess.role !== ThreadAccessRoleChoices.editor);
const handleLeaveThread = async () => {
if (!mailboxAccess || !selectedThread) return;
const decision = await modals.deleteConfirmationModal({
title: t('Leave this thread?'),
children: t(
'You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.',
{ mailboxName: mailboxAccess.mailbox.email }
),
});
if (decision !== 'delete') return;
removeThreadAccess({
id: mailboxAccess.id,
threadId: selectedThread.id,
}, {
onSuccess: () => {
addToast(<ToasterItem><p>{t('You left the thread')}</p></ToasterItem>);
invalidateThreadMessages({
type: 'delete',
metadata: { threadIds: [selectedThread.id] },
});
invalidateThreadsStats();
unselectThread();
}
});
};
return (
<div className="thread-action-bar">
@@ -39,7 +78,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
/>
</Tooltip>
<VerticalSeparator />
{!selectedThread?.is_spam && (
{canShowArchiveCTA && (
canUnarchive ? (
(
<Tooltip content={t('Unarchive')}>
@@ -64,7 +103,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
</Tooltip>
)
)}
{
{canEditThread && (
!selectedThread?.is_spam ? (
<Tooltip content={t('Report as spam')}>
<Button
@@ -86,8 +125,8 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
/>
</Tooltip>
)
}
{
)}
{canEditThread && (
canUndelete ? (
(
<Tooltip content={t('Undelete')}>
@@ -111,8 +150,8 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
/>
</Tooltip>
)
}
<VerticalSeparator />
)}
{canEditThread && <VerticalSeparator />}
{isStarred ? (
<Tooltip content={t('Unstar this thread')}>
<Button
@@ -144,7 +183,12 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
label: t('Mark as unread'),
icon: <Icon name="mark_email_unread" type={IconType.OUTLINED} />,
callback: () => markAsReadAt({ threadIds: [selectedThread!.id], readAt: null, onSuccess: unselectThread })
}
},
...(canLeaveThread ? [{
label: t('Leave this thread'),
icon: <Icon name="exit_to_app" type={IconType.OUTLINED} />,
callback: handleLeaveThread,
}] : []),
]}
>
<Tooltip content={t('More options')}>

View File

@@ -275,7 +275,7 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent
className="thread-event-input__submit-button"
size="small"
variant="tertiary"
icon={<Icon name={isEditing ? "check" : "send"} type={IconType.OUTLINED} size={IconSize.MEDIUM} />}
icon={<Icon name={isEditing ? "check" : "arrow_upward"} type={IconType.OUTLINED} size={IconSize.MEDIUM} />}
onClick={handleSubmit}
disabled={!content.trim() || isPending}
title={isEditing ? t("Save") : t("Send")}

View File

@@ -15,15 +15,20 @@ type ThreadLabelsWidgetProps = {
export const ThreadLabelsWidget = ({ threadId, selectedLabels = [] }: ThreadLabelsWidgetProps) => {
const { t } = useTranslation();
const { selectedMailbox } = useMailboxContext();
const { selectedMailbox, selectedThread } = useMailboxContext();
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
// Labelling a thread is a shared-state mutation: it also requires
// full edit rights on the thread itself (the label endpoint silently
// drops unauthorized threads otherwise).
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
const canAddLabel = canManageLabels && canEditThread;
const {data: labelsList, isLoading: isLoadingLabelsList } = useLabelsList(
{ mailbox_id: selectedMailbox!.id },
{ query: { enabled: canManageLabels } }
{ query: { enabled: canAddLabel } }
);
const [isPopupOpen, setIsPopupOpen] = useState(false);
if (!canManageLabels) return null;
if (!canAddLabel) return null;
if (isLoadingLabelsList) {
return (

View File

@@ -43,6 +43,7 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
}, [message.is_sender, message.to, message.cc, message.bcc]);
const canSendMessages = useAbility(Abilities.CAN_SEND_MESSAGES, selectedMailbox);
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
const canUpdateDeliveryStatus = useAbility(Abilities.CAN_MANAGE_THREAD_DELIVERY_STATUSES, [selectedMailbox!, selectedThread!]);
// Derived state
@@ -117,7 +118,11 @@ export const ThreadMessage = forwardRef<HTMLSpanElement, ThreadMessageProps>(
// Computed flags
const showReplyForm = replyFormMode !== null;
const showReplyButton = canSendMessages && isLatest && !showReplyForm && !message.is_draft && !message.is_trashed && !draftMessage;
// Reply/Reply All/Forward require BOTH sending rights on the mailbox
// AND full edit rights on the thread. A user with only VIEWER access
// (mailbox or thread) must not see these buttons — the backend would
// reject the draft creation anyway.
const showReplyButton = canSendMessages && canEditThread && isLatest && !showReplyForm && !message.is_draft && !message.is_trashed && !draftMessage;
// Handlers
const toggleFold = useCallback(() => {

View File

@@ -10,6 +10,7 @@ import useRead from "@/features/message/use-read";
import useSplitThread from "@/features/message/use-split-thread";
import useTrash from "@/features/message/use-trash";
import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { ThreadMessageActionsProps } from "./types";
const ThreadMessageActions = ({
@@ -32,6 +33,11 @@ const ThreadMessageActions = ({
const { print } = usePrint();
const modals = useModals();
const isThreadSplitEnabled = useFeatureFlag(FEATURE_KEYS.THREAD_SPLIT);
// Full edit rights on the thread — required for reply/forward/delete/split.
// Mark read/unread, print and download are either read-only
// or personal actions and stay available to viewers.
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
const canReply = canSendMessages && canEditThread;
const hasSiblingMessages = useMemo(() => {
if (!selectedThread) return false;
@@ -39,14 +45,13 @@ const ThreadMessageActions = ({
}, [selectedThread]);
const canSplitThread = useMemo(() => {
if (!isThreadSplitEnabled) return false;
if (!isThreadSplitEnabled || !canEditThread) return false;
if (!selectedThread || !hasSiblingMessages) return false;
if (message.is_draft) return false;
if (selectedThread.user_role !== "editor") return false;
// Cannot split at the first message
if (selectedThread.messages[0] === message.id) return false;
return true;
}, [isThreadSplitEnabled, selectedThread, hasSiblingMessages, message.id, message.is_draft]);
}, [isThreadSplitEnabled, selectedThread, hasSiblingMessages, message.id, message.is_draft, canEditThread]);
// Handlers specific to actions
const toggleReadStateFrom = useCallback((is_unread: boolean) => {
@@ -87,12 +92,12 @@ const ThreadMessageActions = ({
}, [message.id]);
const dropdownOptions = [
...(canSendMessages && hasSeveralRecipients ? [{
...(canReply && hasSeveralRecipients ? [{
label: t('Reply all'),
icon: <Icon type={IconType.FILLED} name="reply_all" />,
callback: () => onSetReplyFormMode('reply_all')
}] : []),
...(canSendMessages ? [{
...(canReply ? [{
label: t('Forward'),
icon: <Icon type={IconType.FILLED} name="forward" />,
callback: () => onSetReplyFormMode('forward'),
@@ -124,18 +129,18 @@ const ThreadMessageActions = ({
icon: <Icon type={IconType.FILLED} name="download" />,
callback: handleDownloadRawEmail
},
...(message.is_trashed ? [] : [{
...(canEditThread && !message.is_trashed ? [{
label: t('Delete'),
icon: <Icon type={IconType.FILLED} name="delete" />,
callback: handleMarkAsTrashed
}]),
}] : []),
];
return (
<div className="thread-message__header-actions">
{!isFolded && (
<>
{canSendMessages && (
{canReply && (
<Tooltip content={t('Reply')}>
<Button
color="brand"

View File

@@ -8,6 +8,7 @@ import { Badge } from "@/features/ui/components/badge";
import { ContactChip, ContactChipDeliveryStatus, ContactChipDeliveryAction } from "@/features/ui/components/contact-chip";
import { DateHelper } from "@/features/utils/date-helper";
import useTrash from "@/features/message/use-trash";
import useAbility, { Abilities } from "@/hooks/use-ability";
import { useMailboxContext } from "@/features/providers/mailbox";
import { ThreadMessageHeaderProps } from "./types";
import ThreadMessageActions from "./thread-message-actions";
@@ -28,7 +29,10 @@ const ThreadMessageHeader = ({
const { t, i18n } = useTranslation();
const { user } = useAuth();
const { markAsUntrashed } = useTrash();
const { selectedMailbox } = useMailboxContext();
const { selectedMailbox, selectedThread } = useMailboxContext();
// Untrash is a shared-state mutation — only show the banner action
// to users who have full edit rights on the thread.
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
// Derived state specific to header
const isSuspiciousSender = Boolean(message.stmsg_headers?.['sender-auth'] === 'none');
@@ -129,12 +133,12 @@ const ThreadMessageHeader = ({
type="info"
icon={<span className="material-icons">restore_from_trash</span>}
fullWidth
actions={[
actions={canEditThread ? [
{
label: t('Undelete'),
onClick: handleMarkAsUntrashed,
}
]}
] : undefined}
>
<p>{t('This message has been deleted.')}</p>
</Banner>

View File

@@ -11,8 +11,16 @@ const useArchive = () => {
const { mark, unmark, status } = useFlag('archived', {
toastMessages: {
thread: (count: number) => t('{{count}} threads have been archived.', { count: count, defaultValue_one: 'The thread has been archived.' }),
message: (count: number) => t('{{count}} messages have been archived.', { count: count, defaultValue_one: 'The message has been archived.' }),
thread: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No thread could be archived.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} threads have been archived.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} thread has been archived.' });
return t('{{count}} threads have been archived.', { count: updatedCount, defaultValue_one: 'The thread has been archived.' });
},
message: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No message could be archived.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} messages have been archived.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} message has been archived.' });
return t('{{count}} messages have been archived.', { count: updatedCount, defaultValue_one: 'The message has been archived.' });
},
},
onSuccess: (data) => {
invalidateThreadMessages({

View File

@@ -1,7 +1,7 @@
import { useFlagCreate } from "@/features/api/gen"
import { Thread, Message, FlagEnum, ChangeFlagRequestRequest, Mailbox } from "@/features/api/gen/models"
import { addToast, ToasterItem } from "../ui/components/toaster";
import { toast } from "react-toastify";
import { toast, ToastContentProps } from "react-toastify";
import { useTranslation } from "react-i18next";
type MarkAsOptions = {
@@ -20,8 +20,8 @@ type FlagOptions = {
}
type FlagToastMessages = {
thread: (count: number) => string;
message: (count: number) => string;
thread: (updatedCount: number, submittedCount: number) => string;
message: (updatedCount: number, submittedCount: number) => string;
}
/**
@@ -29,13 +29,23 @@ type FlagToastMessages = {
* !!! Do not use this hook directly, use the specialized hooks instead !!!
*/
const useFlag = (flag: FlagEnum, options?: FlagOptions) => {
const toastId = `${flag.toLowerCase()}_TOAST_ID`;
const toastIdSuffix = `${flag.toLowerCase()}_TOAST_ID`;
const { mutate, status } = useFlagCreate({
mutation: {
onSuccess: (_, { data }) => {
onSuccess: (response, { data }) => {
options?.onSuccess?.(data);
if (options?.showToast !== false && data.value === true) {
const responseData = response.data as Record<string, unknown>;
const updatedCount = typeof responseData.updated_threads === 'number'
? responseData.updated_threads
: undefined;
const threadIds = data.thread_ids ?? [];
const type = updatedCount === undefined ? 'success'
: updatedCount === 0 ? 'error'
: (threadIds.length > 0 && updatedCount < threadIds.length) ? 'warning'
: 'success';
const toastId = `${toastIdSuffix}--${type}`;
addToast(<FlagUpdateSuccessToast
flag={flag}
threadIds={data.thread_ids}
@@ -44,6 +54,7 @@ const useFlag = (flag: FlagEnum, options?: FlagOptions) => {
toastId={toastId}
messages={options?.toastMessages}
onUndo={options?.onSuccess}
updatedCount={updatedCount}
/>, { toastId })
}
},
@@ -82,11 +93,18 @@ type FlagUpdateSuccessToastProps = {
toastId: string;
messages?: FlagToastMessages;
onUndo?: (data: ChangeFlagRequestRequest) => void;
updatedCount?: number;
}
const FlagUpdateSuccessToast = ({ flag, threadIds = [], messageIds = [], mailboxId, toastId, messages, onUndo }: FlagUpdateSuccessToastProps) => {
const FlagUpdateSuccessToast = ({ flag, threadIds = [], messageIds = [], mailboxId, toastId, messages, onUndo, updatedCount, closeToast }: FlagUpdateSuccessToastProps & Partial<ToastContentProps>) => {
const { t } = useTranslation();
const { unmark } = useFlag(flag, { showToast: false });
const isThreadScope = threadIds.length > 0;
const submittedCount = isThreadScope ? threadIds.length : messageIds.length;
const isPartial = isThreadScope && updatedCount !== undefined && updatedCount > 0 && updatedCount < threadIds.length;
const isNone = updatedCount !== undefined && updatedCount === 0 && submittedCount > 0;
const displayCount = isNone ? 0 : isPartial ? updatedCount! : submittedCount;
const undo = () => {
unmark({
threadIds: threadIds,
@@ -98,17 +116,24 @@ const FlagUpdateSuccessToast = ({ flag, threadIds = [], messageIds = [], mailbox
}
});
}
const toastType = isNone ? 'error' : isPartial ? 'warning' : 'info';
const mainMessage = threadIds.length > 0
? (messages?.thread?.(displayCount, submittedCount) ?? t('{{count}} threads have been updated.', { count: displayCount, defaultValue_one: 'The thread has been updated.' }))
: (messages?.message?.(displayCount, submittedCount) ?? t('{{count}} messages have been updated.', { count: displayCount, defaultValue_one: 'The message has been updated.' }));
return (
<ToasterItem
type="info"
actions={[{ label: t('Undo'), onClick: undo }]}
type={toastType}
actions={isNone ? [] : [{ label: t('Undo'), onClick: undo }]}
closeToast={closeToast}
>
<span>{
threadIds.length > 0 ?
(messages?.thread?.(threadIds.length) ?? t('{{count}} threads have been updated.', { count: threadIds.length, defaultValue_one: 'The thread has been updated.' })) :
(messages?.message?.(messageIds.length) ?? t('{{count}} messages have been updated.', { count: messageIds.length, defaultValue_one: 'The message has been updated.' }))
}
</span>
<div>
<p>{mainMessage}</p>
{(isPartial || isNone) && (
<p>{t('You may not have sufficient permissions for all selected threads.')}</p>
)}
</div>
</ToasterItem>
)
};

View File

@@ -10,8 +10,16 @@ const useSpam = () => {
const { invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
const { mark, unmark, status } = useFlag('spam', {
toastMessages: {
thread: (count: number) => t('{{count}} threads have been reported as spam.', { count: count, defaultValue_one: 'The thread has been reported as spam.' }),
message: (count: number) => t('{{count}} messages have been reported as spam.', { count: count, defaultValue_one: 'The message has been reported as spam.' }),
thread: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No thread could be reported as spam.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} threads have been reported as spam.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} thread has been reported as spam.' });
return t('{{count}} threads have been reported as spam.', { count: updatedCount, defaultValue_one: 'The thread has been reported as spam.' });
},
message: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No message could be reported as spam.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} messages have been reported as spam.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} message has been reported as spam.' });
return t('{{count}} messages have been reported as spam.', { count: updatedCount, defaultValue_one: 'The message has been reported as spam.' });
},
},
onSuccess: (data) => {
invalidateThreadMessages({

View File

@@ -19,8 +19,16 @@ const useStarred = () => {
const { mark, unmark, status } = useFlag('starred', {
toastMessages: {
thread: (count: number) => t('{{count}} threads are now starred.', { count, defaultValue_one: 'The thread is now starred.' }),
message: (count: number) => t('{{count}} messages are now starred.', { count, defaultValue_one: 'The message is now starred.' }),
thread: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No thread could be starred.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} threads are now starred.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} thread is now starred.' });
return t('{{count}} threads are now starred.', { count: updatedCount, defaultValue_one: 'The thread is now starred.' });
},
message: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No message could be starred.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} messages are now starred.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} message is now starred.' });
return t('{{count}} messages are now starred.', { count: updatedCount, defaultValue_one: 'The message is now starred.' });
},
},
onSuccess: (data) => {
const starredAt = data.value ? (data.starred_at ?? new Date().toISOString()) : null;

View File

@@ -11,8 +11,16 @@ const useTrash = () => {
const { mark, unmark, status } = useFlag('trashed', {
toastMessages: {
thread: (count: number) => t('{{count}} threads have been deleted.', { count: count, defaultValue_one: 'The thread has been deleted.' }),
message: (count: number) => t('{{count}} messages have been deleted.', { count: count, defaultValue_one: 'The message has been deleted.' }),
thread: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No thread could be deleted.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} threads have been deleted.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} thread has been deleted.' });
return t('{{count}} threads have been deleted.', { count: updatedCount, defaultValue_one: 'The thread has been deleted.' });
},
message: (updatedCount, submittedCount) => {
if (updatedCount === 0) return t('No message could be deleted.');
if (updatedCount < submittedCount) return t('{{count}} out of {{total}} messages have been deleted.', { count: updatedCount, total: submittedCount, defaultValue_one: '{{count}} out of {{total}} message has been deleted.' });
return t('{{count}} messages have been deleted.', { count: updatedCount, defaultValue_one: 'The message has been deleted.' });
},
},
onSuccess: (data) => {
invalidateThreadMessages({

View File

@@ -25,6 +25,13 @@ enum MaildomainAbilities {
CAN_MANAGE_MAILDOMAIN_ACCESSES = "manage_accesses",
}
// Thread abilities computed by the backend `Thread.get_abilities(user, mailbox)`.
// These read the `abilities` dict exposed on the serialized Thread — single
// source of truth, no role logic duplicated on the frontend.
enum ThreadAbilities {
CAN_EDIT_THREAD = "edit",
}
enum ThreadAccessAbilities {
CAN_MANAGE_THREAD_ACCESS = "manage_thread_access",
CAN_MANAGE_THREAD_DELIVERY_STATUSES = "manage_thread_delivery_statuses",
@@ -34,6 +41,7 @@ export const Abilities = {
...UserAbilities,
...MailboxAbilities,
...MaildomainAbilities,
...ThreadAbilities,
...ThreadAccessAbilities,
};
@@ -52,6 +60,10 @@ function useAbility(
ability: MaildomainAbilities,
resource: MailDomainAdmin | null
): boolean;
function useAbility(
ability: ThreadAbilities,
resource: Thread | null
): boolean;
function useAbility(
ability: ThreadAccessAbilities,
resource: [Mailbox, Thread]
@@ -81,6 +93,7 @@ function useAbility(
case Abilities.CAN_MANAGE_MAILDOMAIN_MAILBOXES:
case Abilities.CAN_MANAGE_MAILDOMAIN_ACCESSES:
case Abilities.CAN_MANAGE_SOME_MAILDOMAIN_ACCESSES:
case Abilities.CAN_EDIT_THREAD:
return (resource as ResourceWithAbilities).abilities[ability] === true;
case Abilities.CAN_MANAGE_THREAD_DELIVERY_STATUSES:
case Abilities.CAN_MANAGE_THREAD_ACCESS: