Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ without redeploying the frontend (the flag is pulled from
| `MAX_OUTGOING_BODY_SIZE` | `5242880` | Maximum size in bytes for outgoing email body (text + HTML) (5MB) | Optional |
| `MAX_TEMPLATE_IMAGE_SIZE` | `2097152` | Maximum size in bytes for images embedded in templates and signatures (2MB) | Optional |
| `MAX_RECIPIENTS_PER_MESSAGE` | `500` | Maximum number of recipients per message (to + cc + bcc) | Optional |
| `MAX_THREAD_EVENT_EDIT_DELAY` | `3600` | Time window in seconds during which a ThreadEvent (internal comment) can still be edited or deleted after creation. Set to `0` to disable the restriction. | Optional |

### Model custom attributes schema

Expand Down
27 changes: 26 additions & 1 deletion src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,36 @@ class ThreadEventInline(admin.TabularInline):
extra = 0


class UserEventInline(admin.TabularInline):
"""Inline class for the UserEvent model.

UserEvent entries are created exclusively by business logic (mentions,
assignments) and must never be edited via the admin: editing ``thread_event``
would desynchronize ``user_event.thread`` from
``user_event.thread_event.thread``, breaking mention filters and unread flags.
"""

model = models.UserEvent
readonly_fields = (
"user",
"thread",
"thread_event",
"type",
"read_at",
"created_at",
)
can_delete = False
extra = 0
Comment thread
jbpenrath marked this conversation as resolved.

def has_add_permission(self, request, obj=None):
return False


@admin.register(models.Thread)
class ThreadAdmin(admin.ModelAdmin):
"""Admin class for the Thread model"""

inlines = [ThreadAccessInline, ThreadEventInline]
inlines = [ThreadAccessInline, ThreadEventInline, UserEventInline]
list_display = (
"id",
"subject",
Expand Down
116 changes: 111 additions & 5 deletions src/backend/core/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4852,6 +4852,14 @@
},
"description": "Filter threads with draft messages (1=true, 0=false)."
},
{
"in": "query",
"name": "has_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with any mention (read or unread) for the current user (1=true, 0=false)."
},
Comment thread
jbpenrath marked this conversation as resolved.
{
"in": "query",
"name": "has_messages",
Expand Down Expand Up @@ -4892,6 +4900,14 @@
},
"description": "Filter threads with unread messages (1=true, 0=false). Requires mailbox_id."
},
{
"in": "query",
"name": "has_unread_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with unread mentions for the current user (1=true, 0=false)."
},
{
"in": "query",
"name": "is_spam",
Expand Down Expand Up @@ -5800,6 +5816,49 @@
}
}
},
"/api/v1.0/threads/{thread_id}/events/{id}/read-mention/": {
"patch": {
"operationId": "threads_events_read_mention_partial_update",
"description": "Mark the current user's unread MENTION on this ThreadEvent as read.\n\nReturns 204 even when no UserEvent matches (idempotent); the thread\nevent itself is resolved via the standard ``get_object`` lookup so a\nmissing event yields 404.",
"parameters": [
{
"in": "path",
"name": "id",
"schema": {
"type": "string",
"format": "uuid",
"description": "primary key for the record as UUID"
},
"required": true
},
{
"in": "path",
"name": "thread_id",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"thread-events"
],
"security": [
{
"cookieAuth": []
}
],
"responses": {
"204": {
"description": "No response body"
},
"404": {
"description": "Thread event not found"
}
}
}
},
Comment thread
jbpenrath marked this conversation as resolved.
"/api/v1.0/threads/{thread_id}/users/": {
"get": {
"operationId": "threads_users_list",
Expand Down Expand Up @@ -5877,6 +5936,14 @@
},
"description": "Filter threads with draft messages (1=true, 0=false)."
},
{
"in": "query",
"name": "has_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with any mention (read or unread) for the current user (1=true, 0=false)."
},
{
"in": "query",
"name": "has_sender",
Expand All @@ -5901,6 +5968,14 @@
},
"description": "Filter threads that are trashed (1=true, 0=false)."
},
{
"in": "query",
"name": "has_unread_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with unread mentions for the current user (1=true, 0=false)."
},
{
"in": "query",
"name": "label_slug",
Expand Down Expand Up @@ -5935,10 +6010,12 @@
"all",
"all_unread",
"has_delivery_failed",
"has_delivery_pending"
"has_delivery_pending",
"has_mention",
"has_unread_mention"
]
},
"description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'",
"description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'",
"required": true,
"explode": false,
"style": "form"
Expand Down Expand Up @@ -7132,15 +7209,23 @@
"readOnly": true
},
"count_unread_threads": {
"type": "string",
"type": "integer",
"description": "Return the number of threads with unread messages in the mailbox.",
"readOnly": true
},
"count_threads": {
"type": "string",
"type": "integer",
"description": "Return the number of threads in the mailbox.",
"readOnly": true
},
"count_delivering": {
"type": "string",
"type": "integer",
"description": "Return the number of threads with messages being delivered.",
"readOnly": true
},
"count_unread_mentions": {
"type": "integer",
"description": "Return the number of threads with unread mentions for the current user.",
"readOnly": true
},
Comment thread
jbpenrath marked this conversation as resolved.
"abilities": {
Expand Down Expand Up @@ -7212,6 +7297,7 @@
"abilities",
"count_delivering",
"count_threads",
"count_unread_mentions",
"count_unread_threads",
"email",
"id",
Expand Down Expand Up @@ -8830,6 +8916,10 @@
"type": "boolean",
"readOnly": true
},
"has_unread_mention": {
"type": "boolean",
"readOnly": true
},
"has_trashed": {
"type": "boolean",
"readOnly": true
Expand Down Expand Up @@ -8961,13 +9051,18 @@
"summary": {
"type": "string",
"readOnly": true
},
"events_count": {
"type": "integer",
"readOnly": true
}
},
"required": [
"accesses",
"active_messaged_at",
"archived_messaged_at",
"draft_messaged_at",
"events_count",
"has_active",
"has_archived",
"has_attachments",
Expand All @@ -8979,6 +9074,7 @@
"has_starred",
"has_trashed",
"has_unread",
"has_unread_mention",
"id",
"is_spam",
"is_trashed",
Expand Down Expand Up @@ -9191,6 +9287,14 @@
}
]
},
"has_unread_mention": {
"type": "boolean",
"readOnly": true
},
"is_editable": {
"type": "boolean",
"readOnly": true
},
"created_at": {
"type": "string",
"format": "date-time",
Expand All @@ -9211,7 +9315,9 @@
"channel",
"created_at",
"data",
"has_unread_mention",
"id",
"is_editable",
"thread",
"type",
"updated_at"
Expand Down
54 changes: 43 additions & 11 deletions src/backend/core/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,11 @@ def has_object_permission(self, request, view, obj):
return models.MailboxAccess.objects.filter(mailbox=obj, user=user).exists()

if isinstance(obj, models.ThreadEvent):
thread = obj.thread
has_access = models.ThreadAccess.objects.filter(
thread=thread, mailbox__accesses__user=user
# Write actions are handled by HasThreadEditAccess, so we only
# need to gate read access on any ThreadAccess for the thread.
return models.ThreadAccess.objects.filter(
thread=obj.thread, mailbox__accesses__user=user
).exists()
if not has_access:
return False
# Only the author can update or delete their own events
if view.action in ["update", "partial_update", "destroy"]:
return obj.author_id == user.id
return True

if isinstance(obj, (models.Message, models.Thread)):
thread = obj.thread if isinstance(obj, models.Message) else obj
Expand Down Expand Up @@ -543,10 +538,47 @@ class HasThreadEditAccess(IsAuthenticated):

message = "You do not have permission to perform this action on this thread."

def has_permission(self, request, view):
"""Check editor access up-front on nested thread routes.

On nested routes (e.g. ``/threads/{thread_id}/events/``), the thread
id comes from the URL and we can enforce the editor role before the
view resolves any object — required for ``create`` where no object
exists yet. On top-level routes (e.g. ``/threads/{pk}/``), defer to
``has_object_permission``.
"""
if not super().has_permission(request, view):
return False

thread_id_from_url = view.kwargs.get("thread_id")
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()

def has_object_permission(self, request, view, obj):
"""Check if user has editor access to the thread."""
"""Check editor access on the thread the object belongs to.

Supports both ``Thread`` and ``ThreadEvent`` objects. For
``ThreadEvent``, also enforces that only the event author can perform
update or destroy actions.
"""
if isinstance(obj, models.ThreadEvent):
if (
view.action in ("update", "partial_update", "destroy")
and obj.author_id != request.user.id
):
return False
thread = obj.thread
else:
thread = obj

return models.ThreadAccess.objects.filter(
thread=obj,
thread=thread,
mailbox__accesses__user=request.user,
role__in=enums.THREAD_ROLES_CAN_EDIT,
).exists()
Expand Down
Loading
Loading