diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index d8bb18ab7..697087e8d 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -227,11 +227,21 @@ class ThreadAccessInline(admin.TabularInline): autocomplete_fields = ("mailbox",) +class ThreadEventInline(admin.TabularInline): + """Inline class for the ThreadEvent model""" + + model = models.ThreadEvent + fields = ("type", "channel", "message", "data", "created_at") + readonly_fields = ("created_at",) + autocomplete_fields = ("channel", "message") + extra = 0 + + @admin.register(models.Thread) class ThreadAdmin(admin.ModelAdmin): """Admin class for the Thread model""" - inlines = [ThreadAccessInline] + inlines = [ThreadAccessInline, ThreadEventInline] list_display = ( "id", "subject", diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index c163b4e54..ec3214d63 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -4757,6 +4757,306 @@ } } }, + "/api/v1.0/threads/{thread_id}/events/": { + "get": { + "operationId": "threads_events_list", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "description": "A page number within the paginated result set.", + "schema": { + "type": "integer" + } + }, + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-event" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedThreadEventList" + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "threads_events_create", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-event" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEventCreateRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ThreadEventCreateRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEventCreate" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1.0/threads/{thread_id}/events/{id}/": { + "get": { + "operationId": "threads_events_retrieve", + "description": "ViewSet for ThreadEvent model.", + "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-event" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "threads_events_update", + "description": "ViewSet for ThreadEvent model.", + "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-event" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEventRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ThreadEventRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "threads_events_partial_update", + "description": "ViewSet for ThreadEvent model.", + "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-event" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedThreadEventRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedThreadEventRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "threads_events_destroy", + "description": "ViewSet for ThreadEvent model.", + "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-event" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, "/api/v1.0/threads/stats/": { "get": { "operationId": "threads_stats_retrieve", @@ -6665,6 +6965,37 @@ } } }, + "PaginatedThreadEventList": { + "type": "object", + "required": [ + "count", + "results" + ], + "properties": { + "count": { + "type": "integer", + "example": 123 + }, + "next": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=4" + }, + "previous": { + "type": "string", + "nullable": true, + "format": "uri", + "example": "http://api.example.org/accounts/?page=2" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + } + }, "PaginatedThreadList": { "type": "object", "required": [ @@ -6814,6 +7145,21 @@ } } }, + "PatchedThreadEventRequest": { + "type": "object", + "description": "Serialize thread events.", + "properties": { + "thread": { + "type": "string", + "format": "uuid" + }, + "channel": { + "type": "string", + "format": "uuid" + }, + "data": {} + } + }, "ReadOnlyMessageTemplate": { "type": "object", "description": "Serialize message templates for read-only operations.", @@ -7248,6 +7594,134 @@ "editor" ] }, + "ThreadEvent": { + "type": "object", + "description": "Serialize thread events.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "thread": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "readOnly": true + }, + "channel": { + "type": "string", + "format": "uuid" + }, + "data": {}, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Created on", + "description": "date and time at which a record was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Updated on", + "description": "date and time at which a record was last updated" + } + }, + "required": [ + "channel", + "created_at", + "id", + "thread", + "type", + "updated_at" + ] + }, + "ThreadEventCreate": { + "type": "object", + "description": "Serialize thread events for CREATE operations.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "thread": { + "type": "string", + "readOnly": true + }, + "type": { + "type": "string", + "maxLength": 36 + }, + "channel": { + "type": "string", + "readOnly": true + }, + "data": {}, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Created on", + "description": "date and time at which a record was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Updated on", + "description": "date and time at which a record was last updated" + } + }, + "required": [ + "channel", + "created_at", + "id", + "thread", + "type", + "updated_at" + ] + }, + "ThreadEventCreateRequest": { + "type": "object", + "description": "Serialize thread events for CREATE operations.", + "properties": { + "type": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "data": {} + }, + "required": [ + "type" + ] + }, + "ThreadEventRequest": { + "type": "object", + "description": "Serialize thread events.", + "properties": { + "thread": { + "type": "string", + "format": "uuid" + }, + "channel": { + "type": "string", + "format": "uuid" + }, + "data": {} + }, + "required": [ + "channel", + "thread" + ] + }, "ThreadLabel": { "type": "object", "description": "Serializer to get labels details for a thread.", diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 14839a882..73c67c62f 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -101,28 +101,32 @@ def has_permission(self, request, view): if not IsAuthenticated.has_permission(self, request, view): return False - # This check is primarily for LIST actions based on query params + # This check is primarily for LIST actions based on query params or URL kwargs mailbox_id = request.query_params.get("mailbox_id") # Used by Thread list thread_id = request.query_params.get("thread_id") # Used by Message list + # Also check URL kwargs for nested resources (e.g., /threads/{thread_id}/events/) + thread_id_from_url = ( + view.kwargs.get("thread_id") if hasattr(view, "kwargs") else None + ) + thread_id = thread_id or thread_id_from_url # If it's a detail action (retrieve, update, destroy), object-level permission is checked # by has_object_permission. If it's a list action without filters, deny access. - is_list_action = hasattr(view, "action") and view.action == "list" + action_type = getattr(view, "action", None) - if not is_list_action: + if action_type not in ["list", "create"]: # 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 - # --- The following logic only applies if is_list_action is True --- # - # Check access based on query params for LIST action + # Check access based on query params or URL kwargs for LIST or CREATE action if mailbox_id: # Check if the user has access to this specific mailbox to list threads return models.Mailbox.objects.filter( id=mailbox_id, accesses__user=request.user ).exists() if thread_id: - # Check if the user has access to this specific thread to list messages + # Check if the user has access to this specific thread to list messages/events return models.ThreadAccess.objects.filter( thread_id=thread_id, mailbox__accesses__user=request.user ).exists() @@ -130,15 +134,19 @@ def has_permission(self, request, view): return False # Should not be reached if logic above is correct def has_object_permission(self, request, view, obj): - """Check if user has permission to access the specific object (Message, Thread, Mailbox).""" + """Check if user has permission to access the specific object (Message, Thread, Mailbox, ThreadEvent).""" user = request.user if isinstance(obj, models.Mailbox): # Check access directly on the mailbox return models.MailboxAccess.objects.filter(mailbox=obj, user=user).exists() - if isinstance(obj, (models.Message, models.Thread)): - thread = obj.thread if isinstance(obj, models.Message) else obj - # Check access via the message's thread using ThreadAccess + if isinstance(obj, (models.Message, models.Thread, models.ThreadEvent)): + if isinstance(obj, models.Thread): + thread = obj + else: + thread = obj.thread + + # Check access via the thread using ThreadAccess # First, just check if *any* access exists for the user to this thread. has_access = models.ThreadAccess.objects.filter( thread=thread, mailbox__accesses__user=user @@ -170,7 +178,7 @@ def has_object_permission(self, request, view, obj): ).exists() ): return True - # for retrieve action has_access is already checked above + # for retrieve, create, update actions has_access is already checked above else: return True diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 2d8ed0099..c9429b79d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -792,6 +792,79 @@ class Meta: read_only_fields = ["id", "created_at", "updated_at"] +class ThreadEventCreateSerializer(serializers.ModelSerializer): + """Serialize thread events for CREATE operations.""" + + thread = serializers.SerializerMethodField(read_only=True) + channel = serializers.SerializerMethodField(read_only=True) + message = serializers.SerializerMethodField(read_only=True) + + def get_thread(self, obj): + """Return thread UUID as string.""" + return str(obj.thread.id) + + def get_channel(self, obj): + """Return channel UUID as string or None.""" + return str(obj.channel.id) if obj.channel else None + + def get_message(self, obj): + """Return message UUID as string or None.""" + return str(obj.message.id) if obj.message else None + + class Meta: + model = models.ThreadEvent + fields = ["id", "thread", "type", "channel", "message", "data", "created_at", "updated_at"] + read_only_fields = ["id", "thread", "channel", "message", "created_at", "updated_at"] + + +class ThreadEventSerializer(serializers.ModelSerializer): + """Serialize thread events.""" + + thread = serializers.UUIDField(source="thread.id", format="hex_verbose") + channel = serializers.UUIDField(source="channel.id", format="hex_verbose", allow_null=True) + message = serializers.UUIDField(source="message.id", format="hex_verbose", allow_null=True) + + def validate(self, attrs): + """Ensure read-only fields cannot be updated.""" + if self.instance: + request = self.context.get("request") + if request: + errors = {} + if "thread" in request.data: + errors["thread"] = "This field cannot be updated." + if "channel" in request.data: + errors["channel"] = "This field cannot be updated." + if "message" in request.data: + errors["message"] = "This field cannot be updated." + if "type" in request.data: + errors["type"] = "This field cannot be updated." + if errors: + raise serializers.ValidationError(errors) + return attrs + + class Meta: + model = models.ThreadEvent + fields = [ + "id", + "thread", + "type", + "channel", + "message", + "data", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "thread", + "type", + "channel", + "message", + "created_at", + "updated_at", + ] + + class MailboxAccessReadSerializer(serializers.ModelSerializer): """Serialize mailbox access information for read operations with nested user details. Mailbox context is implied by the URL, so mailbox details are not included here. diff --git a/src/backend/core/api/viewsets/inbound/mta.py b/src/backend/core/api/viewsets/inbound/mta.py index 887fdfdb8..58de052ed 100644 --- a/src/backend/core/api/viewsets/inbound/mta.py +++ b/src/backend/core/api/viewsets/inbound/mta.py @@ -180,9 +180,9 @@ def sanitize_header(header: str) -> str: for recipient in mta_metadata["original_recipients"]: try: - # Call the refactored delivery function which returns True/False - delivered = deliver_inbound_message(recipient, parsed_email, raw_data) - if delivered: + # Call the delivery function which returns Message or None + message = deliver_inbound_message(recipient, parsed_email, raw_data) + if message: success_count += 1 delivery_results[recipient] = "Success" else: diff --git a/src/backend/core/api/viewsets/inbound/webhook.py b/src/backend/core/api/viewsets/inbound/webhook.py new file mode 100644 index 000000000..0e0c3587e --- /dev/null +++ b/src/backend/core/api/viewsets/inbound/webhook.py @@ -0,0 +1,285 @@ +"""Webhook channel implementation for receiving messages from external services.""" + +import logging +from html import escape as html_escape +from secrets import compare_digest + +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.utils import timezone + +from drf_spectacular.utils import extend_schema +from rest_framework import status, viewsets +from rest_framework.authentication import BaseAuthentication +from rest_framework.decorators import action +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.response import Response + +from core import models +from core.api.permissions import IsAuthenticated +from core.mda.inbound import deliver_inbound_message +from core.mda.rfc5322 import compose_email + +logger = logging.getLogger(__name__) + + +class WebhookAuthentication(BaseAuthentication): + """ + Custom authentication for webhook endpoints with configurable auth methods. + Currently supports API Key authentication. + Returns None or (user, auth) + """ + + def authenticate(self, request): + # Get channel ID from header + channel_id = request.headers.get("X-Channel-ID") + if not channel_id: + raise AuthenticationFailed("Missing X-Channel-ID header") + + try: + channel = models.Channel.objects.get(id=channel_id) + except models.Channel.DoesNotExist as e: + raise AuthenticationFailed("Invalid channel ID") from e + + # Get authentication method from channel settings + auth_method = (channel.settings or {}).get("auth_method", "api_key") + + if auth_method == "api_key": + return self._authenticate_api_key(request, channel) + + raise AuthenticationFailed(f"Unsupported authentication method: {auth_method}") + + def _authenticate_api_key(self, request, channel): + """Authenticate using API key from channel settings.""" + api_key = request.headers.get("X-API-Key") + if not api_key: + raise AuthenticationFailed("Missing X-API-Key header") + + expected_api_key = (channel.settings or {}).get("api_key") + if not expected_api_key: + raise AuthenticationFailed("API key not configured for this channel") + + # Use constant-time comparison to prevent timing attacks + if not compare_digest(api_key, expected_api_key): + raise AuthenticationFailed("Invalid API key") + + return (None, {"channel": channel, "auth_method": "api_key"}) + + def authenticate_header(self, request): + """Return the header to be used in the WWW-Authenticate response header.""" + return 'ApiKey realm="Webhook"' + + +class InboundWebhookViewSet(viewsets.GenericViewSet): + """Handles incoming messages from webhooks with configurable authentication.""" + + # Channel metadata + CHANNEL_TYPE = "webhook" + CHANNEL_DESCRIPTION = "Generic webhook integration" + + permission_classes = [IsAuthenticated] + authentication_classes = [WebhookAuthentication] + + @extend_schema(exclude=True) + @action( + detail=False, + methods=["post"], + url_path="message", + url_name="inbound-webhook-message", + ) + def message(self, request): + """Handle incoming webhook message.""" + # TODO: Add rate limiting/throttling + + data = request.data + auth_data = request.auth + channel = auth_data["channel"] + + # Extract message data with standard field names + sender_email = data.get("from", {}).get("email") + sender_name = data.get("from", {}).get("name") + message_text = data.get("message", "") + subject = data.get("subject", "Message from webhook") + + # Validate required fields + if not sender_email: + return Response( + {"detail": "Missing email"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Validate the sender email format + try: + validate_email(sender_email) + except ValidationError: + return Response( + {"detail": "Invalid email format"}, status=status.HTTP_400_BAD_REQUEST + ) + + if not message_text: + return Response( + {"detail": "Missing message"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Get the target mailbox + mailbox = channel.mailbox + if not mailbox: + return Response( + {"detail": "No mailbox configured for this channel"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Determine target email and name + if mailbox.contact: + target_email = mailbox.contact.email + target_name = mailbox.contact.name + else: + target_email = str(mailbox) + target_name = str(mailbox) + + # Build sender information + sender_info = {"email": sender_email} + if sender_name: + sender_info["name"] = sender_name + + # Sanitize headers to prevent header injection + def sanitize_header(header: str) -> str: + return header.replace("\r", "").replace("\n", "")[0:1000] + + # Add webhook-specific headers + prepend_headers = [("X-StMsg-Sender-Auth", "webhook")] + + prepend_headers.append( + ( + "Received", + f"from webhook ({sanitize_header(request.META.get('REMOTE_ADDR'))})", + ) + ) + + # Build a JMAP-like structured format + parsed_email = { + "subject": subject, + "from": sender_info, + "to": [{"name": target_name, "email": target_email}], + "date": timezone.now(), + "htmlBody": [{"content": html_escape(message_text).replace("\n", "
")}], + "textBody": [{"content": message_text}], + } + + # Deliver the message + message = deliver_inbound_message( + target_email, + parsed_email, + compose_email(parsed_email, prepend_headers=prepend_headers), + channel=channel, + ) + + if not message: + return Response( + {"detail": "Failed to deliver message"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + logger.info( + "Successfully created message from webhook for channel %s, sender: %s, message: %s, thread: %s", + channel.id, + sender_email, + message.id, + message.thread.id, + ) + + return Response( + { + "success": True, + "message": "Message delivered successfully", + "message_id": str(message.id), + "thread_id": str(message.thread.id), + } + ) + + @extend_schema(exclude=True) + @action( + detail=False, + methods=["post"], + url_path="threadevent", + url_name="inbound-webhook-threadevent", + ) + def threadevent(self, request): + """Handle incoming webhook thread event.""" + # TODO: Add rate limiting/throttling + + data = request.data + auth_data = request.auth + channel = auth_data["channel"] + + # Extract thread event data + thread_id = data.get("thread_id") + event_type = data.get("type") + event_data = data.get("data", {}) + + # Validate required fields + if not thread_id: + return Response( + {"detail": "Missing thread_id"}, status=status.HTTP_400_BAD_REQUEST + ) + + if not event_type: + return Response( + {"detail": "Missing type"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Validate event type length + if len(event_type) > 36: + return Response( + {"detail": "Type exceeds maximum length of 36 characters"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the target mailbox + mailbox = channel.mailbox + if not mailbox: + return Response( + {"detail": "No mailbox configured for this channel"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # Verify thread exists and mailbox has access to it + try: + thread = models.Thread.objects.get(id=thread_id) + except models.Thread.DoesNotExist: + return Response( + {"detail": "Thread not found"}, status=status.HTTP_404_NOT_FOUND + ) + + # Check if mailbox has access to this thread + thread_access = models.ThreadAccess.objects.filter( + thread=thread, mailbox=mailbox + ).first() + if not thread_access: + return Response( + {"detail": "Mailbox does not have access to this thread"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Create the thread event + thread_event = models.ThreadEvent.objects.create( + thread=thread, + type=event_type, + channel=channel, + data=event_data, + ) + + logger.info( + "Successfully created thread event from webhook for channel %s, thread %s, type %s", + channel.id, + thread.id, + event_type, + ) + + return Response( + { + "success": True, + "message": "Thread event created successfully", + "event_id": str(thread_event.id), + }, + status=status.HTTP_201_CREATED, + ) diff --git a/src/backend/core/api/viewsets/inbound/widget.py b/src/backend/core/api/viewsets/inbound/widget.py index 044dbb58b..7cc156230 100644 --- a/src/backend/core/api/viewsets/inbound/widget.py +++ b/src/backend/core/api/viewsets/inbound/widget.py @@ -149,14 +149,14 @@ def sanitize_header(header: str) -> str: "textBody": [{"content": message_text}], } - delivered = deliver_inbound_message( + message = deliver_inbound_message( target_email, parsed_email, compose_email(parsed_email, prepend_headers=prepend_headers), channel=channel, ) - if not delivered: + if not message: return Response( {"detail": "Failed to deliver message"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/src/backend/core/api/viewsets/thread_event.py b/src/backend/core/api/viewsets/thread_event.py new file mode 100644 index 000000000..0fc99921f --- /dev/null +++ b/src/backend/core/api/viewsets/thread_event.py @@ -0,0 +1,58 @@ +"""API ViewSet for ThreadEvent model.""" + +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, viewsets + +from core import models + +from .. import permissions, serializers + + +@extend_schema(tags=["thread-event"]) +class ThreadEventViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.RetrieveModelMixin, +): + """ViewSet for ThreadEvent model.""" + + serializer_class = serializers.ThreadEventSerializer + permission_classes = [ + permissions.IsAuthenticated, + permissions.IsAllowedToAccess, + ] + lookup_field = "id" + lookup_url_kwarg = "id" + queryset = ( + models.ThreadEvent.objects.select_related("thread") + .select_related("channel") + .select_related("message") + .all() + ) + + def get_serializer_class(self): + """Use create serializer for CREATE, default for all other operations.""" + if self.action == "create": + return serializers.ThreadEventCreateSerializer + return serializers.ThreadEventSerializer + + def get_queryset(self): + """Restrict results to thread events for the specified thread. + ThreadAccess is checked by IsAllowedToAccess permission class. + """ + # Get thread_id from URL kwargs (provided by nested router) + thread_id = self.kwargs.get("thread_id") + if not thread_id: + return models.ThreadEvent.objects.none() + + # Filter by thread_id only - access control handled by permission class + return self.queryset.filter(thread_id=thread_id).order_by("created_at") + + def perform_create(self, serializer): + """Set the thread from URL kwargs when creating a ThreadEvent.""" + thread_id = self.kwargs.get("thread_id") + thread = models.Thread.objects.get(id=thread_id) + serializer.save(thread=thread) diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index a9571d0f2..4398c318c 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -122,6 +122,18 @@ class Meta: ) +class ChannelFactory(factory.django.DjangoModelFactory): + """A factory to create channels for testing purposes.""" + + class Meta: + model = models.Channel + + name = factory.Sequence(lambda n: f"Test Channel {n}") + type = factory.fuzzy.FuzzyChoice(["widget", "mta"]) + settings = factory.Dict({"config": {"enabled": True}}) + mailbox = factory.SubFactory(MailboxFactory) + + class ThreadFactory(factory.django.DjangoModelFactory): """A factory to random threads for testing purposes.""" @@ -145,6 +157,21 @@ class Meta: ) +class ThreadEventFactory(factory.django.DjangoModelFactory): + """A factory to create thread events for testing purposes.""" + + class Meta: + model = models.ThreadEvent + + thread = factory.SubFactory(ThreadFactory) + type = factory.Faker( + "word", ext_word_list=["notification", "arbitrary_block", "action_button"] + ) + channel = factory.SubFactory(ChannelFactory) + message = None # Optional - can be set explicitly in tests + data = factory.Faker("pydict", nb_elements=3, value_types=["str", "int"]) + + class ContactFactory(factory.django.DjangoModelFactory): """A factory to random contacts for testing purposes.""" @@ -246,18 +273,6 @@ def _adjust_kwargs(cls, **kwargs): return kwargs -class ChannelFactory(factory.django.DjangoModelFactory): - """A factory to create channels for testing purposes.""" - - class Meta: - model = models.Channel - - name = factory.Sequence(lambda n: f"Test Channel {n}") - type = factory.fuzzy.FuzzyChoice(["widget", "mta"]) - settings = factory.Dict({"config": {"enabled": True}}) - mailbox = factory.SubFactory(MailboxFactory) - - class BlobFactory(factory.django.DjangoModelFactory): """A factory to create blobs for testing purposes.""" diff --git a/src/backend/core/mda/inbound.py b/src/backend/core/mda/inbound.py index 42ce96380..69d9c2f41 100644 --- a/src/backend/core/mda/inbound.py +++ b/src/backend/core/mda/inbound.py @@ -337,10 +337,14 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat imap_labels: Optional[List[str]] = None, imap_flags: Optional[List[str]] = None, channel: Optional[models.Channel] = None, -) -> bool: # Return True on success, False on failure +) -> Optional[models.Message]: # Return Message on success, None on failure """Deliver a parsed inbound email message to the correct mailbox and thread. raw_data is not parsed again, just stored as is. + + Returns: + models.Message: The created or existing message on success + None: On failure """ message_flags = {} thread_flags = {} @@ -350,11 +354,11 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat mailbox = check_local_recipient(recipient_email, create_if_missing=True) except Exception as e: logger.exception("Error checking local recipient: %s", e) - return False + return None if not mailbox: logger.warning("Invalid recipient address: %s", recipient_email) - return False + return None # --- 2. Check for Duplicate Message --- # mime_id = parsed_email.get("messageId", parsed_email.get("message_id")) @@ -379,7 +383,7 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat mime_id, mailbox.id, ) - return True # Return success since we handled the duplicate gracefully + return existing_message # Return existing message since we handled the duplicate gracefully # --- 3. Find or Create Thread --- # try: @@ -443,14 +447,14 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat ) except (DjangoDbError, ValidationError) as e: logger.error("Failed to find or create thread for %s: %s", recipient_email, e) - return False # Indicate failure + return None # Indicate failure except Exception as e: logger.exception( "Unexpected error finding/creating thread for %s: %s", recipient_email, e, ) - return False + return None if is_import: # get labels from parsed_email @@ -521,7 +525,7 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat mailbox.id, e, ) - return False # Indicate failure + return None # Indicate failure except Exception as e: logger.exception( "Unexpected error with sender contact %s in mailbox %s: %s", @@ -529,7 +533,7 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat mailbox.id, e, ) - return False + return None # --- 5. Create Message --- # try: @@ -588,14 +592,14 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat thread.save(update_fields=thread_flags.keys()) except (DjangoDbError, ValidationError) as e: logger.error("Failed to create message in thread %s: %s", thread.id, e) - return False # Indicate failure + return None # Indicate failure except Exception as e: logger.exception( "Unexpected error creating message in thread %s: %s", thread.id, e, ) - return False + return None # --- 6. Create Recipient Contacts and Links --- # # deduplicate recipients @@ -733,4 +737,4 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat mailbox.id, thread.id, ) - return True # Indicate success + return message # Return the created message diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index 55d1a63dc..edeeb878b 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -342,10 +342,12 @@ def _mark_delivered( try: if parsed_email is None: parsed_email = parse_email_message(blob_content) - delivered = deliver_inbound_message( + delivered_message = deliver_inbound_message( recipient_email, parsed_email, blob_content ) - _mark_delivered(recipient_email, delivered, True) + _mark_delivered( + recipient_email, delivered_message is not None, True + ) except Exception as e: logger.error( "Failed to deliver internal message to %s: %s", diff --git a/src/backend/core/migrations/0012_threadevent.py b/src/backend/core/migrations/0012_threadevent.py new file mode 100644 index 000000000..e6bc27590 --- /dev/null +++ b/src/backend/core/migrations/0012_threadevent.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.13 on 2025-11-02 10:26 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_alter_messagetemplate_type'), + ] + + operations = [ + migrations.CreateModel( + name='ThreadEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('type', models.CharField(max_length=36, verbose_name='type')), + ('data', models.JSONField(blank=True, default=dict, verbose_name='data')), + ('channel', models.ForeignKey(blank=True, help_text='Channel that created this event', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thread_events', to='core.channel')), + ('thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.thread')), + ], + options={ + 'verbose_name': 'thread event', + 'verbose_name_plural': 'thread events', + 'db_table': 'messages_threadevent', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 9134628ac..7f59b7e75 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1123,6 +1123,41 @@ def __str__(self): return f"{self.thread} - {self.mailbox} - {self.role}" +class ThreadEvent(BaseModel): + """Thread event model to store different types of events in a thread.""" + + thread = models.ForeignKey( + "Thread", on_delete=models.CASCADE, related_name="events" + ) + type = models.CharField(_("type"), max_length=36) + channel = models.ForeignKey( + "Channel", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="thread_events", + help_text=_("Channel that created this event"), + ) + message = models.ForeignKey( + "Message", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="thread_events", + help_text=_("Message linked to this event"), + ) + data = models.JSONField(_("data"), default=dict, blank=True) + + class Meta: + db_table = "messages_threadevent" + verbose_name = _("thread event") + verbose_name_plural = _("thread events") + ordering = ["created_at"] + + def __str__(self): + return f"{self.thread} - {self.type}" + + class Contact(BaseModel): """Contact model to store contact information.""" diff --git a/src/backend/core/tests/api/test_inbound_mta.py b/src/backend/core/tests/api/test_inbound_mta.py index e2f828def..cb1b29623 100644 --- a/src/backend/core/tests/api/test_inbound_mta.py +++ b/src/backend/core/tests/api/test_inbound_mta.py @@ -5,7 +5,8 @@ import datetime import hashlib import json -from unittest.mock import ANY, patch +import uuid +from unittest.mock import ANY, MagicMock, patch from django.conf import settings from django.test import override_settings @@ -135,7 +136,9 @@ def test_valid_email_submission( "to": [], } mock_parse.return_value = parsed_email_mock - mock_deliver.return_value = True + mock_message = MagicMock() + mock_message.id = uuid.uuid4() + mock_deliver.return_value = mock_message mailbox = factories.MailboxFactory() email = f"{mailbox.local_part}@{mailbox.domain.name}" @@ -247,7 +250,7 @@ def test_delivery_total_failure( """Test that if all deliveries fail, a 500 is returned.""" parsed_email_mock = {"subject": "Test"} mock_parse.return_value = parsed_email_mock - mock_deliver.return_value = False # Fails for all calls + mock_deliver.return_value = None # Fails for all calls recipients = ["fail1@example.com", "fail2@example.com"] token = valid_jwt_token(sample_email, {"original_recipients": recipients}) diff --git a/src/backend/core/tests/api/test_inbound_webhook.py b/src/backend/core/tests/api/test_inbound_webhook.py new file mode 100644 index 000000000..924dbebac --- /dev/null +++ b/src/backend/core/tests/api/test_inbound_webhook.py @@ -0,0 +1,408 @@ +"""Tests for webhook channel implementation.""" + +import uuid + +from django.test import RequestFactory + +import pytest +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.test import APIClient + +from core import enums, factories, models +from core.api.viewsets.inbound.webhook import ( + WebhookAuthentication, +) + + +@pytest.fixture(name="api_client") +def fixture_api_client(): + """Create an API client for testing.""" + return APIClient() + + +@pytest.fixture(name="channel_with_api_key") +def fixture_channel_with_api_key(): + """Create a channel with API key authentication configured.""" + mailbox = factories.MailboxFactory() + return factories.ChannelFactory( + type="webhook", + mailbox=mailbox, + settings={"auth_method": "api_key", "api_key": "test-api-key-123"}, + ) + + +@pytest.fixture(name="channel_without_api_key") +def fixture_channel_without_api_key(): + """Create a channel without API key configured.""" + mailbox = factories.MailboxFactory() + return factories.ChannelFactory( + type="webhook", + mailbox=mailbox, + settings={ + "auth_method": "api_key", + }, + ) + + +class TestWebhookAuthentication: + """Test webhook authentication functionality.""" + + def test_authenticate_missing_channel_id(self): + """Test authentication fails when channel ID is missing.""" + auth = WebhookAuthentication() + factory = RequestFactory() + request = factory.post("/test/") + + with pytest.raises(AuthenticationFailed, match="Missing X-Channel-ID header"): + auth.authenticate(request) + + @pytest.mark.django_db + def test_authenticate_invalid_channel_id(self): + """Test authentication fails with invalid channel ID.""" + auth = WebhookAuthentication() + factory = RequestFactory() + request = factory.post("/test/", HTTP_X_CHANNEL_ID=str(uuid.uuid4())) + + with pytest.raises(AuthenticationFailed, match="Invalid channel ID"): + auth.authenticate(request) + + @pytest.mark.django_db + def test_authenticate_missing_api_key_header(self, channel_with_api_key): + """Test authentication fails when API key header is missing.""" + auth = WebhookAuthentication() + factory = RequestFactory() + request = factory.post("/test/", HTTP_X_CHANNEL_ID=str(channel_with_api_key.id)) + + with pytest.raises(AuthenticationFailed, match="Missing X-API-Key header"): + auth.authenticate(request) + + @pytest.mark.django_db + def test_authenticate_missing_api_key_config(self, channel_without_api_key): + """Test authentication fails when API key is not configured.""" + auth = WebhookAuthentication() + factory = RequestFactory() + request = factory.post( + "/test/", + HTTP_X_CHANNEL_ID=str(channel_without_api_key.id), + HTTP_X_API_KEY="some-key", + ) + + with pytest.raises(AuthenticationFailed, match="API key not configured"): + auth.authenticate(request) + + @pytest.mark.django_db + def test_authenticate_invalid_api_key(self, channel_with_api_key): + """Test authentication fails with invalid API key.""" + auth = WebhookAuthentication() + factory = RequestFactory() + request = factory.post( + "/test/", + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="wrong-key", + ) + + with pytest.raises(AuthenticationFailed, match="Invalid API key"): + auth.authenticate(request) + + @pytest.mark.django_db + def test_authenticate_success(self, channel_with_api_key): + """Test successful authentication with valid API key.""" + auth = WebhookAuthentication() + factory = RequestFactory() + request = factory.post( + "/test/", + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + ) + + user, auth_data = auth.authenticate(request) + + assert user is None + assert auth_data["channel"] == channel_with_api_key + assert auth_data["auth_method"] == "api_key" + + def test_authenticate_header(self): + """Test authenticate header method.""" + auth = WebhookAuthentication() + factory = RequestFactory() + request = factory.post("/test/") + + assert auth.authenticate_header(request) == 'ApiKey realm="Webhook"' + + +class TestInboundWebhookMessage: + """Test webhook message endpoint.""" + + @pytest.mark.django_db + def test_message_success(self, api_client, channel_with_api_key): + """Test successful message delivery.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/message/", + data={ + "email": "test@example.com", + "message": "Test message from webhook", + "subject": "Test Subject", + "name": "Test User", + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["message"] == "Message delivered successfully" + assert "message_id" in data + assert "thread_id" in data + # Verify IDs are valid UUIDs + uuid.UUID(data["message_id"]) + uuid.UUID(data["thread_id"]) + + @pytest.mark.django_db + def test_message_missing_email(self, api_client, channel_with_api_key): + """Test delivery fails when email is missing.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/message/", + data={"message": "Test message"}, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + ) + + assert response.status_code == 400 + assert "Missing email" in response.json()["detail"] + + @pytest.mark.django_db + def test_message_invalid_email(self, api_client, channel_with_api_key): + """Test delivery fails with invalid email format.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/message/", + data={ + "email": "invalid-email", + "message": "Test message", + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + ) + + assert response.status_code == 400 + assert "Invalid email format" in response.json()["detail"] + + @pytest.mark.django_db + def test_message_missing_message(self, api_client, channel_with_api_key): + """Test delivery fails when message is missing.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/message/", + data={"email": "test@example.com"}, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + ) + + assert response.status_code == 400 + assert "Missing message" in response.json()["detail"] + + @pytest.mark.django_db + def test_message_unauthorized(self, api_client, channel_with_api_key): + """Test message endpoint requires authentication.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/message/", + data={ + "email": "test@example.com", + "message": "Test message", + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + ) + + assert response.status_code == 401 + + +class TestInboundWebhookThreadEvent: + """Test webhook threadevent endpoint.""" + + @pytest.fixture(name="thread_with_access") + def fixture_thread_with_access(self, channel_with_api_key): + """Create a thread with access for the channel's mailbox.""" + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=channel_with_api_key.mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + return thread + + @pytest.mark.django_db + def test_threadevent_success( + self, api_client, channel_with_api_key, thread_with_access + ): + """Test successful thread event creation.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "thread_id": str(thread_with_access.id), + "type": "notification", + "data": {"message": "X read message Y", "user_id": "123"}, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + format="json", + ) + + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + assert data["message"] == "Thread event created successfully" + assert "event_id" in data + + # Verify the event was created + event = models.ThreadEvent.objects.get(id=data["event_id"]) + assert event.thread == thread_with_access + assert event.type == "notification" + assert event.channel == channel_with_api_key + assert event.data == {"message": "X read message Y", "user_id": "123"} + + @pytest.mark.django_db + def test_threadevent_missing_thread_id(self, api_client, channel_with_api_key): + """Test threadevent fails when thread_id is missing.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "type": "notification", + "data": {}, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + format="json", + ) + + assert response.status_code == 400 + assert "Missing thread_id" in response.json()["detail"] + + @pytest.mark.django_db + def test_threadevent_missing_type( + self, api_client, channel_with_api_key, thread_with_access + ): + """Test threadevent fails when type is missing.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "thread_id": str(thread_with_access.id), + "data": {}, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + format="json", + ) + + assert response.status_code == 400 + assert "Missing type" in response.json()["detail"] + + @pytest.mark.django_db + def test_threadevent_type_too_long( + self, api_client, channel_with_api_key, thread_with_access + ): + """Test threadevent fails when type exceeds max length.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "thread_id": str(thread_with_access.id), + "type": "a" * 37, # 37 chars, max is 36 + "data": {}, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + format="json", + ) + + assert response.status_code == 400 + assert "exceeds maximum length" in response.json()["detail"] + + @pytest.mark.django_db + def test_threadevent_thread_not_found(self, api_client, channel_with_api_key): + """Test threadevent fails when thread does not exist.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "thread_id": str(uuid.uuid4()), + "type": "notification", + "data": {}, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + format="json", + ) + + assert response.status_code == 404 + assert "Thread not found" in response.json()["detail"] + + @pytest.mark.django_db + def test_threadevent_no_access(self, api_client, channel_with_api_key): + """Test threadevent fails when mailbox has no access to thread.""" + thread = factories.ThreadFactory() + # Don't create ThreadAccess, so mailbox has no access + + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "thread_id": str(thread.id), + "type": "notification", + "data": {}, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + format="json", + ) + + assert response.status_code == 403 + assert "does not have access" in response.json()["detail"] + + @pytest.mark.django_db + def test_threadevent_unauthorized( + self, api_client, channel_with_api_key, thread_with_access + ): + """Test threadevent endpoint requires authentication.""" + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "thread_id": str(thread_with_access.id), + "type": "notification", + "data": {}, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + format="json", + ) + + assert response.status_code == 401 + + @pytest.mark.django_db + def test_threadevent_with_complex_data( + self, api_client, channel_with_api_key, thread_with_access + ): + """Test threadevent with complex JSON data.""" + complex_data = { + "type": "arbitrary_block", + "data": { + "type": "iframe", + "src": "https://example.com/widget", + "width": "100%", + "height": "400px", + "config": {"theme": "dark", "language": "en"}, + }, + } + + response = api_client.post( + "/api/v1.0/inbound/webhook/threadevent/", + data={ + "thread_id": str(thread_with_access.id), + **complex_data, + }, + HTTP_X_CHANNEL_ID=str(channel_with_api_key.id), + HTTP_X_API_KEY="test-api-key-123", + format="json", + ) + + assert response.status_code == 201 + data = response.json() + assert data["success"] is True + + # Verify the complex data was stored + event = models.ThreadEvent.objects.get(id=data["event_id"]) + assert event.data == complex_data["data"] diff --git a/src/backend/core/tests/api/test_inbound_widget.py b/src/backend/core/tests/api/test_inbound_widget.py index 204b1725d..e2b58dcd0 100644 --- a/src/backend/core/tests/api/test_inbound_widget.py +++ b/src/backend/core/tests/api/test_inbound_widget.py @@ -1,6 +1,7 @@ """Tests for widget inbound API endpoints.""" -from unittest.mock import patch +import uuid +from unittest.mock import MagicMock, patch from django.core.exceptions import ValidationError @@ -175,7 +176,9 @@ def test_deliver_success( self, mock_deliver, api_client, channel, channel_with_mailbox_contact ): """Test successful message delivery.""" - mock_deliver.return_value = True + mock_message = MagicMock() + mock_message.id = uuid.uuid4() + mock_deliver.return_value = mock_message data = { "email": "sender@example.com", @@ -266,7 +269,9 @@ def test_deliver_no_mailbox_configured(self, api_client, channel_without_mailbox @patch("core.api.viewsets.inbound.widget.deliver_inbound_message") def test_deliver_with_custom_settings(self, mock_deliver, api_client): """Test deliver with custom channel settings.""" - mock_deliver.return_value = True + mock_message = MagicMock() + mock_message.id = uuid.uuid4() + mock_deliver.return_value = mock_message channel = factories.ChannelFactory( type="widget", diff --git a/src/backend/core/tests/api/test_thread_event.py b/src/backend/core/tests/api/test_thread_event.py new file mode 100644 index 000000000..2cd1daa5e --- /dev/null +++ b/src/backend/core/tests/api/test_thread_event.py @@ -0,0 +1,435 @@ +"""Tests for the ThreadEvent API endpoints.""" + +import uuid + +from django.urls import reverse + +import pytest +from rest_framework import status + +from core import enums, factories, models + +pytestmark = pytest.mark.django_db + + +def get_thread_event_url(thread_id, event_id=None): + """Helper function to get the thread event URL.""" + if event_id: + return reverse( + "thread-event-detail", kwargs={"thread_id": thread_id, "id": event_id} + ) + return reverse("thread-event-list", kwargs={"thread_id": thread_id}) + + +@pytest.fixture(name="mailbox_with_access") +def fixture_mailbox_with_access(): + """Create a mailbox with access for a user.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + return user, mailbox + + +@pytest.fixture(name="thread_with_access") +def fixture_thread_with_access(mailbox_with_access): + """Create a thread with access for a mailbox.""" + user, mailbox = mailbox_with_access + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.VIEWER, + ) + return user, mailbox, thread + + +class TestThreadEventList: + """Test the GET /threads/{thread_id}/events/ endpoint.""" + + def test_list_thread_events_success( + self, api_client, thread_with_access, django_assert_num_queries + ): + """Test listing thread events of a thread.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + # Create some events for the thread + factories.ThreadEventFactory.create_batch(5, thread=thread, type="notification") + factories.ThreadEventFactory.create_batch( + 3, thread=thread, type="arbitrary_block" + ) + + # Create events for other threads + other_thread = factories.ThreadFactory() + factories.ThreadEventFactory.create_batch(2, thread=other_thread) + + with django_assert_num_queries(3): + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 8 + assert response.data["results"][0]["thread"] == str(thread.id) + + def test_list_thread_events_ordered_by_created_at( + self, api_client, thread_with_access + ): + """Test that thread events are ordered by created_at.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + event1 = factories.ThreadEventFactory(thread=thread) + event2 = factories.ThreadEventFactory(thread=thread) + event3 = factories.ThreadEventFactory(thread=thread) + + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + results = response.data["results"] + assert len(results) == 3 + # Events should be ordered by created_at (oldest first) + assert results[0]["id"] == str(event1.id) + assert results[1]["id"] == str(event2.id) + assert results[2]["id"] == str(event3.id) + + def test_list_thread_events_forbidden(self, api_client): + """Test listing thread events without permission.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + # Create a thread that the user doesn't have access to + thread = factories.ThreadFactory() + factories.ThreadEventFactory(thread=thread) + + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_list_thread_events_unauthorized(self, api_client): + """Test listing thread events without authentication.""" + thread = factories.ThreadFactory() + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestThreadEventRetrieve: + """Test the GET /threads/{thread_id}/events/{id}/ endpoint.""" + + def test_retrieve_thread_event_success(self, api_client, thread_with_access): + """Test retrieving a single thread event.""" + user, mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + channel = factories.ChannelFactory(mailbox=mailbox) + event = factories.ThreadEventFactory( + thread=thread, type="action_button", channel=channel + ) + + response = api_client.get(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == str(event.id) + assert response.data["thread"] == str(thread.id) + assert response.data["type"] == "action_button" + assert response.data["channel"] == str(channel.id) + + def test_retrieve_thread_event_forbidden(self, api_client): + """Test retrieving a thread event without permission.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + thread = factories.ThreadFactory() + event = factories.ThreadEventFactory(thread=thread) + + response = api_client.get(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_retrieve_thread_event_not_found(self, api_client, thread_with_access): + """Test retrieving a non-existent thread event.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_event_url(thread.id, uuid.uuid4())) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_retrieve_thread_event_unauthorized(self, api_client): + """Test retrieving a thread event without authentication.""" + thread = factories.ThreadFactory() + event = factories.ThreadEventFactory(thread=thread) + + response = api_client.get(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestThreadEventCreate: + """Test the POST /threads/{thread_id}/events/ endpoint.""" + + def test_create_thread_event_success(self, api_client, thread_with_access): + """Test creating a thread event successfully.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + data = { + "type": "notification", + "data": {"message": "X read message Y", "user_id": "123"}, + } + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["thread"] == str(thread.id) + assert response.data["type"] == "notification" + # channel should be None since it's not set via header in regular API + assert response.data["channel"] is None + assert response.data["data"] == data["data"] + + # Verify the event was created in the database + event = models.ThreadEvent.objects.get(id=response.data["id"]) + assert event.thread == thread + assert event.type == "notification" + assert event.channel is None + + @pytest.mark.parametrize( + "event_type", + ["notification", "arbitrary_block", "action_button", "custom_type"], + ) + def test_create_thread_event_all_types( + self, api_client, thread_with_access, event_type + ): + """Test creating thread events with different event types.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + data = {"type": event_type, "data": {"test": "data"}} + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["type"] == event_type + # channel should be None since it's not set via header in regular API + assert response.data["channel"] is None + + def test_create_thread_event_with_complex_data( + self, api_client, thread_with_access + ): + """Test creating a thread event with complex JSON data.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + complex_data = { + "type": "arbitrary_block", + "data": { + "type": "iframe", + "src": "https://example.com/widget", + "width": "100%", + "height": "400px", + "config": {"theme": "dark", "language": "en"}, + }, + } + + response = api_client.post( + get_thread_event_url(thread.id), complex_data, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["data"] == complex_data["data"] + # channel should be None since it's not set via header in regular API + assert response.data["channel"] is None + + def test_create_thread_event_forbidden(self, api_client): + """Test creating a thread event without permission.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + thread = factories.ThreadFactory() + data = {"type": "notification", "data": {}} + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_create_thread_event_type_too_long(self, api_client, thread_with_access): + """Test creating a thread event with type that exceeds max length.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + data = {"type": "a" * 37, "data": {}} # 37 chars, max is 36 + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_create_thread_event_unauthorized(self, api_client): + """Test creating a thread event without authentication.""" + thread = factories.ThreadFactory() + data = {"type": "notification", "data": {}} + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestThreadEventUpdate: + """Test the PUT/PATCH /threads/{thread_id}/events/{id}/ endpoint.""" + + def test_update_thread_event_success(self, api_client, thread_with_access): + """Test updating a thread event successfully.""" + user, mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + channel = factories.ChannelFactory(mailbox=mailbox) + event = factories.ThreadEventFactory( + thread=thread, type="notification", channel=channel + ) + original_channel_id = str(channel.id) + + data = { + "data": {"label": "Approve", "action": "approve"}, + } + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), data, format="json" + ) + assert response.status_code == status.HTTP_200_OK + # type, thread, and channel should remain unchanged (read-only) + assert response.data["type"] == "notification" + assert response.data["thread"] == str(thread.id) + assert response.data["channel"] == original_channel_id + assert response.data["data"] == data["data"] + + def test_update_thread_event_partial(self, api_client, thread_with_access): + """Test partially updating a thread event.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory( + thread=thread, + type="notification", + data={"old": "data"}, + ) + + data = {"data": {"new": "data"}} + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), data, format="json" + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["data"] == {"new": "data"} + + def test_update_thread_event_readonly_fields(self, api_client, thread_with_access): + """Test that thread, type, and channel cannot be updated (read-only fields should return errors).""" + user, mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + channel = factories.ChannelFactory(mailbox=mailbox) + event = factories.ThreadEventFactory( + thread=thread, type="notification", channel=channel + ) + + # Try to update thread - should return validation error + different_thread = factories.ThreadFactory() + data = { + "thread": str(different_thread.id), + "data": {"test": "data"}, + } + response = api_client.patch( + get_thread_event_url(thread.id, event.id), data, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "thread" in response.data + assert "cannot be updated" in str(response.data["thread"][0]).lower() + + # Try to update channel - should return validation error + different_channel = factories.ChannelFactory(mailbox=mailbox) + data = { + "channel": str(different_channel.id), + "data": {"test": "data"}, + } + response = api_client.patch( + get_thread_event_url(thread.id, event.id), data, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "channel" in response.data + assert "cannot be updated" in str(response.data["channel"][0]).lower() + + # Try to update type - should return validation error + data = { + "type": "action_button", + "data": {"test": "data"}, + } + response = api_client.patch( + get_thread_event_url(thread.id, event.id), data, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "type" in response.data or "Cannot change" in str(response.data) + + # Verify original values remain unchanged in database + event.refresh_from_db() + assert event.thread.id == thread.id + assert event.type == "notification" + assert event.channel.id == channel.id + + def test_update_thread_event_forbidden(self, api_client): + """Test updating a thread event without permission.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + thread = factories.ThreadFactory() + event = factories.ThreadEventFactory(thread=thread) + + data = {"data": {"new": "data"}} + response = api_client.patch( + get_thread_event_url(thread.id, event.id), data, format="json" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_update_thread_event_unauthorized(self, api_client): + """Test updating a thread event without authentication.""" + thread = factories.ThreadFactory() + event = factories.ThreadEventFactory(thread=thread) + + response = api_client.patch(get_thread_event_url(thread.id, event.id), {}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestThreadEventDelete: + """Test the DELETE /threads/{thread_id}/events/{id}/ endpoint.""" + + def test_delete_thread_event_success(self, api_client, mailbox_with_access): + """Test deleting a thread event successfully.""" + user, mailbox = mailbox_with_access + api_client.force_authenticate(user=user) + + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event = factories.ThreadEventFactory(thread=thread) + + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify the event was deleted + assert not models.ThreadEvent.objects.filter(id=event.id).exists() + + def test_delete_thread_event_forbidden(self, api_client): + """Test deleting a thread event without permission.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + thread = factories.ThreadFactory() + event = factories.ThreadEventFactory(thread=thread) + + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_delete_thread_event_not_found(self, api_client, thread_with_access): + """Test deleting a non-existent thread event.""" + user, _mailbox, thread = thread_with_access + api_client.force_authenticate(user=user) + + response = api_client.delete(get_thread_event_url(thread.id, uuid.uuid4())) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_thread_event_unauthorized(self, api_client): + """Test deleting a thread event without authentication.""" + thread = factories.ThreadFactory() + event = factories.ThreadEventFactory(thread=thread) + + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/src/backend/core/tests/importer/test_import_service.py b/src/backend/core/tests/importer/test_import_service.py index 9f36704da..a85d711cb 100644 --- a/src/backend/core/tests/importer/test_import_service.py +++ b/src/backend/core/tests/importer/test_import_service.py @@ -157,9 +157,8 @@ def test_import_file_eml_by_superuser_sync(admin_user, mailbox, eml_key): original_deliver = deliver_inbound_message def mock_deliver(recipient_email, parsed_email, raw_data, **kwargs): - # Call the original function to create the message - original_deliver(recipient_email, parsed_email, raw_data, **kwargs) - return True + # Call the original function to create the message and return it + return original_deliver(recipient_email, parsed_email, raw_data, **kwargs) with patch("core.mda.inbound.deliver_inbound_message", side_effect=mock_deliver): # Create a mock task instance @@ -260,9 +259,8 @@ def test_import_file_eml_by_user_with_access_sync(user, mailbox, eml_key, mock_r original_deliver = deliver_inbound_message def mock_deliver(recipient_email, parsed_email, raw_data, **kwargs): - # Call the original function to create the message - original_deliver(recipient_email, parsed_email, raw_data, **kwargs) - return True + # Call the original function to create the message and return it + return original_deliver(recipient_email, parsed_email, raw_data, **kwargs) with patch("core.mda.inbound.deliver_inbound_message", side_effect=mock_deliver): # Create a mock task instance diff --git a/src/backend/core/tests/mda/test_inbound.py b/src/backend/core/tests/mda/test_inbound.py index 19c2778f8..9e84d8236 100644 --- a/src/backend/core/tests/mda/test_inbound.py +++ b/src/backend/core/tests/mda/test_inbound.py @@ -260,11 +260,11 @@ def test_basic_delivery_new_thread( assert models.Contact.objects.count() == 0 assert models.Message.objects.count() == 0 - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is True + assert message is not None mock_find_thread.assert_called_once_with(sample_parsed_email, target_mailbox) assert models.Thread.objects.count() == 1 @@ -324,11 +324,11 @@ def test_basic_delivery_existing_thread( assert models.Thread.objects.count() == 1 assert models.Message.objects.count() == 0 - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is True + assert message is not None mock_find_thread.assert_called_once_with(sample_parsed_email, target_mailbox) assert models.Thread.objects.count() == 1 # No new thread assert models.Message.objects.count() == 1 @@ -346,11 +346,11 @@ def test_mailbox_creation_enabled(self, sample_parsed_email, raw_email_data): local_part="newuser", domain__name="autocreate.test" ).exists() - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is True + assert message is not None assert models.Mailbox.objects.filter( local_part="newuser", domain__name="autocreate.test" ).exists() @@ -366,11 +366,11 @@ def test_mailbox_creation_disabled(self, sample_parsed_email, raw_email_data): local_part="nonexistent", domain__name="disabled.test" ).exists() - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is False + assert message is None assert not models.Mailbox.objects.filter( local_part="nonexistent", domain__name="disabled.test" ).exists() @@ -395,11 +395,11 @@ def test_contact_creation( email="cc@example.com", mailbox=target_mailbox ).exists() - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is True + assert message is not None assert models.Contact.objects.filter( email=sender_email, mailbox=target_mailbox ).exists() @@ -421,11 +421,11 @@ def test_invalid_sender_email_validation( "email": "invalid-email-format", } - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is True # Should still succeed using fallback + assert message is not None # Should still succeed using fallback message = models.Message.objects.first() assert message is not None fallback_sender_email = f"invalid-sender@{target_mailbox.domain.name}" @@ -440,11 +440,11 @@ def test_no_sender_email(self, target_mailbox, sample_parsed_email, raw_email_da recipient_addr = f"{target_mailbox.local_part}@{target_mailbox.domain.name}" del sample_parsed_email["from"] # Remove From header - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is True + assert message is not None message = models.Message.objects.first() assert message is not None fallback_sender_email = f"unknown-sender@{target_mailbox.domain.name}" @@ -467,11 +467,11 @@ def test_invalid_recipient_email_skipped( {"name": "Another Invalid", "email": "@no-localpart.com"}, # Invalid ] - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, sample_parsed_email, raw_email_data ) - assert success is True # Delivery succeeds overall + assert message is not None # Delivery succeeds overall message = models.Message.objects.first() assert message is not None # Only the valid recipient should have a MessageRecipient link @@ -502,8 +502,8 @@ def test_email_exchange_single_thread(self): } raw_email_1 = b"Raw for message 1" - success1 = deliver_inbound_message(addr2, parsed_email_1, raw_email_1) - assert success1 is True + message1 = deliver_inbound_message(addr2, parsed_email_1, raw_email_1) + assert message1 is not None assert models.Thread.objects.filter(accesses__mailbox=mailbox1).count() == 0 assert models.Thread.objects.filter(accesses__mailbox=mailbox2).count() == 1 thread2 = models.Thread.objects.get(accesses__mailbox=mailbox2) @@ -525,8 +525,8 @@ def test_email_exchange_single_thread(self): } raw_email_2 = b"Raw for message 2" - success2 = deliver_inbound_message(addr1, parsed_email_2, raw_email_2) - assert success2 is True + message2 = deliver_inbound_message(addr1, parsed_email_2, raw_email_2) + assert message2 is not None assert models.Thread.objects.filter(accesses__mailbox=mailbox1).count() == 1 assert models.Thread.objects.filter(accesses__mailbox=mailbox2).count() == 1 thread1 = models.Thread.objects.get(accesses__mailbox=mailbox1) @@ -550,8 +550,8 @@ def test_email_exchange_single_thread(self): } raw_email_3 = b"Raw for message 3" - success3 = deliver_inbound_message(addr2, parsed_email_3, raw_email_3) - assert success3 is True + message3 = deliver_inbound_message(addr2, parsed_email_3, raw_email_3) + assert message3 is not None # Counts should remain 1 thread per mailbox assert models.Thread.objects.filter(accesses__mailbox=mailbox1).count() == 1 assert models.Thread.objects.filter(accesses__mailbox=mailbox2).count() == 1 @@ -581,11 +581,11 @@ def test_deliver_message_with_empty_subject(self, target_mailbox, raw_email_data "date": timezone.now(), } - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, parsed_email_empty_subject, raw_email_data ) - assert success is True + assert message is not None assert models.Message.objects.count() == 1 assert models.Thread.objects.count() == 1 @@ -615,11 +615,11 @@ def test_deliver_message_with_null_subject(self, target_mailbox, raw_email_data) "date": timezone.now(), } - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, parsed_email_null_subject, raw_email_data ) - assert success is True + assert message is not None assert models.Message.objects.count() == 1 assert models.Thread.objects.count() == 1 @@ -651,11 +651,11 @@ def test_deliver_message_without_subject_field( "date": timezone.now(), } - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, parsed_email_no_subject, raw_email_data ) - assert success is True + assert message is not None assert models.Message.objects.count() == 1 assert models.Thread.objects.count() == 1 @@ -689,10 +689,10 @@ def test_deliver_message_with_very_long_subject( } # This should now succeed with truncated subject - success = deliver_inbound_message( + message = deliver_inbound_message( recipient_addr, parsed_email_long_subject, raw_email_data ) - assert success is True + assert message is not None assert models.Message.objects.count() == 1 assert models.Thread.objects.count() == 1 @@ -724,10 +724,10 @@ def test_thread_subject_consistency_with_empty_subject( "date": timezone.now(), } - success1 = deliver_inbound_message( + message1 = deliver_inbound_message( recipient_addr, parsed_email_1, raw_email_data ) - assert success1 is True + assert message1 is not None # Second message with empty subject (should join same thread) parsed_email_2 = { @@ -740,10 +740,10 @@ def test_thread_subject_consistency_with_empty_subject( "date": timezone.now(), } - success2 = deliver_inbound_message( + message2 = deliver_inbound_message( recipient_addr, parsed_email_2, raw_email_data ) - assert success2 is True + assert message2 is not None # Verify both messages are in the same thread with empty subject assert models.Thread.objects.count() == 1 diff --git a/src/backend/core/tests/tasks/test_task_importer.py b/src/backend/core/tests/tasks/test_task_importer.py index 143ab4119..39746e754 100644 --- a/src/backend/core/tests/tasks/test_task_importer.py +++ b/src/backend/core/tests/tasks/test_task_importer.py @@ -89,7 +89,11 @@ class TestProcessMboxFileTask: def test_task_process_mbox_file_success(self, mailbox, sample_mbox_content): """Test successful MBOX file processing.""" # Mock deliver_inbound_message to always succeed - with patch("core.mda.inbound.deliver_inbound_message", return_value=True): + mock_message = MagicMock() + mock_message.id = uuid.uuid4() + with patch( + "core.mda.inbound.deliver_inbound_message", return_value=mock_message + ): # Create a mock task instance mock_task = MagicMock() mock_task.update_state = MagicMock() @@ -200,9 +204,9 @@ def mock_deliver(recipient_email, parsed_email, raw_data, **kwargs): # Get the subject from the parsed email dictionary subject = parsed_email.get("headers", {}).get("subject", "") - # Return False for Test Message 2 without creating the message + # Return None for Test Message 2 without creating the message if subject == "Test Message 2": - return False + return None # For other messages, call the original function to create the message return original_deliver(recipient_email, parsed_email, raw_data, **kwargs) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index ef48298b3..186d7b9e4 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -12,6 +12,7 @@ from core.api.viewsets.flag import ChangeFlagView from core.api.viewsets.import_message import ImportViewSet, MessagesArchiveUploadViewSet from core.api.viewsets.inbound.mta import InboundMTAViewSet +from core.api.viewsets.inbound.webhook import InboundWebhookViewSet from core.api.viewsets.inbound.widget import InboundWidgetViewSet from core.api.viewsets.label import LabelViewSet from core.api.viewsets.mailbox import MailboxViewSet @@ -35,6 +36,7 @@ from core.api.viewsets.task import TaskDetailView from core.api.viewsets.thread import ThreadViewSet from core.api.viewsets.thread_access import ThreadAccessViewSet +from core.api.viewsets.thread_event import ThreadEventViewSet from core.api.viewsets.user import UserViewSet from core.authentication.urls import urlpatterns as oidc_urls @@ -54,11 +56,14 @@ basename="messages-archive-upload", ) -# Router for /threads/{thread_id}/accesses/ +# Router for /threads/{thread_id}/accesses/ and /threads/{thread_id}/events/ thread_access_nested_router = DefaultRouter() thread_access_nested_router.register( r"accesses", ThreadAccessViewSet, basename="thread-access" ) +thread_access_nested_router.register( + r"events", ThreadEventViewSet, basename="thread-event" +) # Router for /mailboxes/{mailbox_id}/accesses/ mailbox_access_nested_router = DefaultRouter() @@ -82,6 +87,9 @@ # Router for /inbound/ inbound_nested_router = DefaultRouter() inbound_nested_router.register(r"mta", InboundMTAViewSet, basename="inbound-mta") +inbound_nested_router.register( + r"webhook", InboundWebhookViewSet, basename="inbound-webhook" +) inbound_nested_router.register( r"widget", InboundWidgetViewSet, basename="inbound-widget" ) @@ -121,7 +129,7 @@ "threads//", include( thread_access_nested_router.urls - ), # Includes /threads/{id}/accesses/ + ), # Includes /threads/{id}/accesses/ and /threads/{id}/events/ ), path( "mailboxes//", diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 39db920b2..8013c10e8 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -983,7 +983,12 @@ class Production(Base): SECURE_HSTS_PRELOAD = True SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_SSL_REDIRECT = True - SECURE_REDIRECT_EXEMPT = ["^__lbheartbeat__", "^__heartbeat__", "^api/v1\\.0/mta/", "^api/v1\\.0/inbound/mta/"] + SECURE_REDIRECT_EXEMPT = [ + "^__lbheartbeat__", + "^__heartbeat__", + "^api/v1\\.0/mta/", + "^api/v1\\.0/inbound/mta/", + ] # Modern browsers require to have the `secure` attribute on cookies with `Samesite=none` CSRF_COOKIE_SECURE = True diff --git a/src/frontend/src/features/api/gen/index.ts b/src/frontend/src/features/api/gen/index.ts index 0d9929ff4..6654531fd 100644 --- a/src/frontend/src/features/api/gen/index.ts +++ b/src/frontend/src/features/api/gen/index.ts @@ -13,6 +13,7 @@ export * from "./placeholders/placeholders"; export * from "./tasks/tasks"; export * from "./threads/threads"; export * from "./thread-access/thread-access"; +export * from "./thread-event/thread-event"; export * from "./admin-users-list/admin-users-list"; export * from "./users/users"; export * from "./models"; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 2227992ae..ef7448f1f 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -96,12 +96,14 @@ export * from "./paginated_mailbox_access_read_list"; export * from "./paginated_mailbox_admin_list"; export * from "./paginated_message_list"; export * from "./paginated_thread_access_list"; +export * from "./paginated_thread_event_list"; export * from "./paginated_thread_list"; export * from "./patched_label_request"; export * from "./patched_mailbox_access_write_request"; export * from "./patched_mailbox_admin_partial_update_payload_request"; export * from "./patched_message_template_request"; export * from "./patched_thread_access_request"; +export * from "./patched_thread_event_request"; export * from "./placeholders_retrieve200"; export * from "./read_only_message_template"; export * from "./reset_password_error"; @@ -121,12 +123,17 @@ export * from "./thread_access"; export * from "./thread_access_detail"; export * from "./thread_access_request"; export * from "./thread_access_role_choices"; +export * from "./thread_event"; +export * from "./thread_event_create"; +export * from "./thread_event_create_request"; +export * from "./thread_event_request"; export * from "./thread_label"; export * from "./threads_accesses_create_params"; export * from "./threads_accesses_destroy_params"; export * from "./threads_accesses_list_params"; export * from "./threads_accesses_partial_update_params"; export * from "./threads_accesses_update_params"; +export * from "./threads_events_list_params"; export * from "./threads_list_params"; export * from "./threads_refresh_summary_create200"; export * from "./threads_stats_retrieve200"; diff --git a/src/frontend/src/features/api/gen/models/paginated_thread_event_list.ts b/src/frontend/src/features/api/gen/models/paginated_thread_event_list.ts new file mode 100644 index 000000000..44e569323 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/paginated_thread_event_list.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { ThreadEvent } from "./thread_event"; + +export interface PaginatedThreadEventList { + count: number; + /** @nullable */ + next?: string | null; + /** @nullable */ + previous?: string | null; + results: ThreadEvent[]; +} diff --git a/src/frontend/src/features/api/gen/models/patched_thread_event_request.ts b/src/frontend/src/features/api/gen/models/patched_thread_event_request.ts new file mode 100644 index 000000000..507110275 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/patched_thread_event_request.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize thread events. + */ +export interface PatchedThreadEventRequest { + thread?: string; + channel?: string; + data?: unknown; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event.ts b/src/frontend/src/features/api/gen/models/thread_event.ts new file mode 100644 index 000000000..e51bd5f17 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize thread events. + */ +export interface ThreadEvent { + /** primary key for the record as UUID */ + readonly id: string; + thread: string; + readonly type: string; + channel: string; + data?: unknown; + /** date and time at which a record was created */ + readonly created_at: string; + /** date and time at which a record was last updated */ + readonly updated_at: string; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_create.ts b/src/frontend/src/features/api/gen/models/thread_event_create.ts new file mode 100644 index 000000000..bb52764dd --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_create.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize thread events for CREATE operations. + */ +export interface ThreadEventCreate { + /** primary key for the record as UUID */ + readonly id: string; + readonly thread: string; + /** @maxLength 36 */ + type: string; + readonly channel: string; + data?: unknown; + /** date and time at which a record was created */ + readonly created_at: string; + /** date and time at which a record was last updated */ + readonly updated_at: string; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_create_request.ts b/src/frontend/src/features/api/gen/models/thread_event_create_request.ts new file mode 100644 index 000000000..1edc6b2b2 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_create_request.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize thread events for CREATE operations. + */ +export interface ThreadEventCreateRequest { + /** + * @minLength 1 + * @maxLength 36 + */ + type: string; + data?: unknown; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_request.ts b/src/frontend/src/features/api/gen/models/thread_event_request.ts new file mode 100644 index 000000000..9e99414d3 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_request.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serialize thread events. + */ +export interface ThreadEventRequest { + thread: string; + channel: string; + data?: unknown; +} diff --git a/src/frontend/src/features/api/gen/models/threads_events_list_params.ts b/src/frontend/src/features/api/gen/models/threads_events_list_params.ts new file mode 100644 index 000000000..7d5c7b561 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/threads_events_list_params.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type ThreadsEventsListParams = { + /** + * A page number within the paginated result set. + */ + page?: number; +}; diff --git a/src/frontend/src/features/api/gen/thread-event/thread-event.ts b/src/frontend/src/features/api/gen/thread-event/thread-event.ts new file mode 100644 index 000000000..83b29d412 --- /dev/null +++ b/src/frontend/src/features/api/gen/thread-event/thread-event.ts @@ -0,0 +1,859 @@ +/** + * Generated by orval v7.10.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + PaginatedThreadEventList, + PatchedThreadEventRequest, + ThreadEvent, + ThreadEventCreate, + ThreadEventCreateRequest, + ThreadEventRequest, + ThreadsEventsListParams, +} from ".././models"; + +import { fetchAPI } from "../../fetch-api"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsListResponse200 = { + data: PaginatedThreadEventList; + status: 200; +}; + +export type threadsEventsListResponseComposite = threadsEventsListResponse200; + +export type threadsEventsListResponse = threadsEventsListResponseComposite & { + headers: Headers; +}; + +export const getThreadsEventsListUrl = ( + threadId: string, + params?: ThreadsEventsListParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1.0/threads/${threadId}/events/?${stringifiedParams}` + : `/api/v1.0/threads/${threadId}/events/`; +}; + +export const threadsEventsList = async ( + threadId: string, + params?: ThreadsEventsListParams, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsListUrl(threadId, params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getThreadsEventsListQueryKey = ( + threadId: string, + params?: ThreadsEventsListParams, +) => { + return [ + `/api/v1.0/threads/${threadId}/events/`, + ...(params ? [params] : []), + ] as const; +}; + +export const getThreadsEventsListQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + threadId: string, + params?: ThreadsEventsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getThreadsEventsListQueryKey(threadId, params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + threadsEventsList(threadId, params, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!threadId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ThreadsEventsListQueryResult = NonNullable< + Awaited> +>; +export type ThreadsEventsListQueryError = unknown; + +export function useThreadsEventsList< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + params: undefined | ThreadsEventsListParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsList< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + params?: ThreadsEventsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsList< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + params?: ThreadsEventsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useThreadsEventsList< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + params?: ThreadsEventsListParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getThreadsEventsListQueryOptions( + threadId, + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsCreateResponse201 = { + data: ThreadEventCreate; + status: 201; +}; + +export type threadsEventsCreateResponseComposite = + threadsEventsCreateResponse201; + +export type threadsEventsCreateResponse = + threadsEventsCreateResponseComposite & { + headers: Headers; + }; + +export const getThreadsEventsCreateUrl = (threadId: string) => { + return `/api/v1.0/threads/${threadId}/events/`; +}; + +export const threadsEventsCreate = async ( + threadId: string, + threadEventCreateRequest: ThreadEventCreateRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsCreateUrl(threadId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(threadEventCreateRequest), + }, + ); +}; + +export const getThreadsEventsCreateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; data: ThreadEventCreateRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; data: ThreadEventCreateRequest }, + TContext +> => { + const mutationKey = ["threadsEventsCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; data: ThreadEventCreateRequest } + > = (props) => { + const { threadId, data } = props ?? {}; + + return threadsEventsCreate(threadId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsCreateMutationResult = NonNullable< + Awaited> +>; +export type ThreadsEventsCreateMutationBody = ThreadEventCreateRequest; +export type ThreadsEventsCreateMutationError = unknown; + +export const useThreadsEventsCreate = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; data: ThreadEventCreateRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; data: ThreadEventCreateRequest }, + TContext +> => { + const mutationOptions = getThreadsEventsCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsRetrieveResponse200 = { + data: ThreadEvent; + status: 200; +}; + +export type threadsEventsRetrieveResponseComposite = + threadsEventsRetrieveResponse200; + +export type threadsEventsRetrieveResponse = + threadsEventsRetrieveResponseComposite & { + headers: Headers; + }; + +export const getThreadsEventsRetrieveUrl = (threadId: string, id: string) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsRetrieve = async ( + threadId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsRetrieveUrl(threadId, id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getThreadsEventsRetrieveQueryKey = ( + threadId: string, + id: string, +) => { + return [`/api/v1.0/threads/${threadId}/events/${id}/`] as const; +}; + +export const getThreadsEventsRetrieveQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getThreadsEventsRetrieveQueryKey(threadId, id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + threadsEventsRetrieve(threadId, id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!(threadId && id), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ThreadsEventsRetrieveQueryResult = NonNullable< + Awaited> +>; +export type ThreadsEventsRetrieveQueryError = unknown; + +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = unknown, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getThreadsEventsRetrieveQueryOptions( + threadId, + id, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsUpdateResponse200 = { + data: ThreadEvent; + status: 200; +}; + +export type threadsEventsUpdateResponseComposite = + threadsEventsUpdateResponse200; + +export type threadsEventsUpdateResponse = + threadsEventsUpdateResponseComposite & { + headers: Headers; + }; + +export const getThreadsEventsUpdateUrl = (threadId: string, id: string) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsUpdate = async ( + threadId: string, + id: string, + threadEventRequest: ThreadEventRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsUpdateUrl(threadId, id), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(threadEventRequest), + }, + ); +}; + +export const getThreadsEventsUpdateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext +> => { + const mutationKey = ["threadsEventsUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; id: string; data: ThreadEventRequest } + > = (props) => { + const { threadId, id, data } = props ?? {}; + + return threadsEventsUpdate(threadId, id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsUpdateMutationResult = NonNullable< + Awaited> +>; +export type ThreadsEventsUpdateMutationBody = ThreadEventRequest; +export type ThreadsEventsUpdateMutationError = unknown; + +export const useThreadsEventsUpdate = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext +> => { + const mutationOptions = getThreadsEventsUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsPartialUpdateResponse200 = { + data: ThreadEvent; + status: 200; +}; + +export type threadsEventsPartialUpdateResponseComposite = + threadsEventsPartialUpdateResponse200; + +export type threadsEventsPartialUpdateResponse = + threadsEventsPartialUpdateResponseComposite & { + headers: Headers; + }; + +export const getThreadsEventsPartialUpdateUrl = ( + threadId: string, + id: string, +) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsPartialUpdate = async ( + threadId: string, + id: string, + patchedThreadEventRequest: PatchedThreadEventRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsPartialUpdateUrl(threadId, id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(patchedThreadEventRequest), + }, + ); +}; + +export const getThreadsEventsPartialUpdateMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext +> => { + const mutationKey = ["threadsEventsPartialUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; id: string; data: PatchedThreadEventRequest } + > = (props) => { + const { threadId, id, data } = props ?? {}; + + return threadsEventsPartialUpdate(threadId, id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsPartialUpdateMutationResult = NonNullable< + Awaited> +>; +export type ThreadsEventsPartialUpdateMutationBody = PatchedThreadEventRequest; +export type ThreadsEventsPartialUpdateMutationError = unknown; + +export const useThreadsEventsPartialUpdate = < + TError = unknown, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext +> => { + const mutationOptions = getThreadsEventsPartialUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsDestroyResponse204 = { + data: void; + status: 204; +}; + +export type threadsEventsDestroyResponseComposite = + threadsEventsDestroyResponse204; + +export type threadsEventsDestroyResponse = + threadsEventsDestroyResponseComposite & { + headers: Headers; + }; + +export const getThreadsEventsDestroyUrl = (threadId: string, id: string) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsDestroy = async ( + threadId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsDestroyUrl(threadId, id), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getThreadsEventsDestroyMutationOptions = < + TError = unknown, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext +> => { + const mutationKey = ["threadsEventsDestroy"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; id: string } + > = (props) => { + const { threadId, id } = props ?? {}; + + return threadsEventsDestroy(threadId, id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsDestroyMutationResult = NonNullable< + Awaited> +>; + +export type ThreadsEventsDestroyMutationError = unknown; + +export const useThreadsEventsDestroy = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; id: string }, + TContext +> => { + const mutationOptions = getThreadsEventsDestroyMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss new file mode 100644 index 000000000..52be4edbb --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss @@ -0,0 +1,74 @@ +.thread-event { + margin: var(--c--theme--spacings--base) 0; + padding: var(--c--theme--spacings--base); + background-color: var(--c--theme--colors--greyscale-100); + border-radius: var(--c--theme--spacings--2xs); + border: 1px solid var(--c--theme--colors--greyscale-300); +} + +.thread-event__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--c--theme--spacings--xs); + margin-bottom: var(--c--theme--spacings--xs); + font-size: 0.875rem; +} + +.thread-event__type { + font-weight: 600; + text-transform: uppercase; + color: var(--c--theme--colors--greyscale-700); +} + +.thread-event__channel { + color: var(--c--theme--colors--greyscale-600); + font-size: 0.75rem; + font-style: italic; +} + +.thread-event__date { + color: var(--c--theme--colors--greyscale-500); + font-size: 0.75rem; + margin-left: auto; +} + +.thread-event__content { + margin-top: var(--c--theme--spacings--xs); +} + +.thread-event__json { + margin: 0; + padding: var(--c--theme--spacings--base); + background-color: var(--c--theme--colors--greyscale-50); + border-radius: var(--c--theme--spacings--2xs); + border: 1px solid var(--c--theme--colors--greyscale-200); + font-size: 0.75rem; + font-family: 'Courier New', monospace; + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.thread-event__iframe-container { + width: 100%; + overflow: hidden; + border: 1px solid var(--c--theme--colors--greyscale-200); + border-radius: var(--c--theme--spacings--2xs); +} + +.thread-event__iframe { + display: block; + width: 100%; + border: none; +} + +.thread-event__error { + margin: 0; + padding: var(--c--theme--spacings--base); + color: var(--c--theme--colors--error); + font-size: 0.875rem; +} + diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx new file mode 100644 index 000000000..d152067ce --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx @@ -0,0 +1,74 @@ +import { useTranslation } from "react-i18next"; + +type ThreadEventProps = { + type: string; + channel?: string; + data: Record; + createdAt: string; +}; + +type IframeData = { + src?: string; + width?: string | number; + height?: string | number; + title?: string; + sandbox?: string; + allow?: string; + [key: string]: unknown; +}; + +export const ThreadEvent = ({ type, channel, data, createdAt }: ThreadEventProps) => { + const { t } = useTranslation(); + + // For iframe type, render only the iframe with border + if (type === "iframe") { + const iframeData = data as IframeData; + const src = iframeData.src; + + if (!src || typeof src !== "string") { + // Error message if src is missing or invalid - fallback to default display + return ( +
+

+ {t("This event contains an invalid iframe.")} +

+
+ ); + } + + return ( +
+