From d3964de3cc9291468c6d26ffafeba3b705d5f675 Mon Sep 17 00:00:00 2001 From: Niels Kersic Date: Sun, 7 Dec 2025 12:05:26 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9D=8C(backend)=20add=20cancel=20send=20?= =?UTF-8?q?endpoint=20for=20undo=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new API endpoint to cancel queued send tasks and revert messages to draft state. This enables the undo send feature. --- src/backend/core/api/serializers.py | 3 +- src/backend/core/api/viewsets/cancel_send.py | 121 +++++++++++++++++++ src/backend/core/api/viewsets/send.py | 30 ++++- src/backend/core/mda/outbound.py | 35 ++++-- src/backend/core/urls.py | 6 + 5 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 src/backend/core/api/viewsets/cancel_send.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d89674a11..924e18ae1 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1460,9 +1460,10 @@ class SendMessageSerializer(serializers.Serializer): archive = serializers.BooleanField(required=False, default=False) textBody = serializers.CharField(required=False, allow_blank=True) htmlBody = serializers.CharField(required=False, allow_blank=True) + delay = serializers.IntegerField(required=False, default=0, min_value=0, max_value=30) class Meta: - fields = ["messageId", "senderId", "archive", "textBody", "htmlBody"] + fields = ["messageId", "senderId", "archive", "textBody", "htmlBody", "delay"] def create(self, validated_data): """This serializer is only used to validate the data, not to create or update.""" diff --git a/src/backend/core/api/viewsets/cancel_send.py b/src/backend/core/api/viewsets/cancel_send.py new file mode 100644 index 000000000..5b7cc6957 --- /dev/null +++ b/src/backend/core/api/viewsets/cancel_send.py @@ -0,0 +1,121 @@ +"""API ViewSet for canceling queued send tasks.""" + +import logging + +from celery.result import AsyncResult +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import exceptions as drf_exceptions +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from core import models +from core.mda.tasks import celery_app + +from .. import permissions, serializers + +logger = logging.getLogger(__name__) + + +class CancelSendSerializer(drf_serializers.Serializer): + """Serializer for canceling send tasks.""" + + taskId = drf_serializers.CharField(required=True, help_text="Celery task ID to cancel") + messageId = drf_serializers.UUIDField(required=True, help_text="Message ID to keep as draft") + + +@extend_schema( + tags=["messages"], + request=CancelSendSerializer, + responses={ + 200: OpenApiExample( + "Success", + value={"detail": "Send cancelled successfully."}, + ), + 400: OpenApiExample( + "Validation Error", + value={"detail": "Invalid request data."}, + ), + 404: OpenApiExample( + "Not Found", + value={"detail": "Message not found."}, + ), + }, + description=""" + Cancel a queued send task and keep the message as a draft. + + This endpoint revokes the Celery task responsible for sending the message + and ensures the message remains in draft state. + """, + examples=[ + OpenApiExample( + "Cancel Send", + value={ + "taskId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "messageId": "123e4567-e89b-12d3-a456-426614174000", + }, + ), + ], +) +class CancelSendView(APIView): + """Cancel a queued send task.""" + + permission_classes = [permissions.IsAllowedToAccess] + + def post(self, request): + """Cancel a queued send task.""" + serializer = CancelSendSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + task_id = serializer.validated_data.get("taskId") + message_id = serializer.validated_data.get("messageId") + + # Revoke the Celery task + celery_app.control.revoke(task_id, terminate=True) + logger.info(f"Revoked Celery task {task_id} for message {message_id}") + + # Ensure message stays as draft + try: + message = models.Message.objects.get(id=message_id) + + # Ensure message is marked as draft + # Note: draft_blob is preserved during prepare_outbound_message(), + # so it should still exist and doesn't need restoration + needs_save = False + update_fields = [] + + if not message.is_draft: + message.is_draft = True + needs_save = True + update_fields.append("is_draft") + + # Remove the blob (MIME) if it exists - drafts shouldn't have a blob + # The blob was created by prepare_outbound_message() but we're cancelling + if message.blob: + try: + message.blob.delete() + logger.info(f"Deleted blob {message.blob_id} for cancelled message {message_id}") + except Exception as e: + logger.warning(f"Failed to delete blob for message {message_id}: {e}") + + message.blob = None + needs_save = True + update_fields.append("blob") + + if needs_save: + message.save(update_fields=update_fields) + # Update thread stats since message state changed + message.thread.update_stats() + logger.info(f"Message {message_id} reverted to draft state after send cancellation") + else: + logger.info(f"Message {message_id} was already in correct draft state") + except models.Message.DoesNotExist as e: + # Message might have been deleted, but task was revoked anyway + logger.warning(f"Message {message_id} not found during cancel, but task {task_id} was revoked") + raise drf_exceptions.NotFound("Message not found.") from e + + return Response( + {"detail": "Send cancelled successfully."}, + status=status.HTTP_200_OK, + ) diff --git a/src/backend/core/api/viewsets/send.py b/src/backend/core/api/viewsets/send.py index 55e98e645..0a946d6fa 100644 --- a/src/backend/core/api/viewsets/send.py +++ b/src/backend/core/api/viewsets/send.py @@ -81,6 +81,7 @@ def post(self, request): message_id = serializer.validated_data.get("messageId") sender_id = serializer.validated_data.get("senderId") must_archive = serializer.validated_data.get("archive", False) is True + delay = serializer.validated_data.get("delay", 0) try: mailbox_sender = models.Mailbox.objects.get(id=sender_id) @@ -120,14 +121,31 @@ def post(self, request): ) # Launch async task for sending the message - task = send_message_task.delay(str(message.id), must_archive=must_archive) + if delay > 0: + # For delayed send, keep message as draft until task executes + task = send_message_task.apply_async( + args=[str(message.id)], + kwargs={"must_archive": must_archive}, + countdown=delay, + ) + else: + # For immediate send, mark as non-draft now (original behavior) + message.is_draft = False + message.save(update_fields=["is_draft"]) + task = send_message_task.delay(str(message.id), must_archive=must_archive) # --- Finalize --- - # Message state should be updated by prepare_outbound_message/send_message - # Refresh from DB to get final state (e.g., is_draft=False) + # Refresh from DB to get final state message.refresh_from_db() - # Update thread stats after un-drafting - message.thread.update_stats() + # Update thread stats after un-drafting (only if not delayed) + if delay == 0: + message.thread.update_stats() + + # Serialize the message to return in response + message_serializer = serializers.MessageSerializer(message) - return Response({"task_id": task.id}, status=status.HTTP_200_OK) + return Response( + {"task_id": task.id, "message": message_serializer.data}, + status=status.HTTP_200_OK, + ) diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index 900cd1b8b..7710ea5ad 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -262,11 +262,8 @@ def prepare_outbound_message( content_type="message/rfc822", ) - draft_blob = message.draft_blob - + # Keep draft_blob reference for potential undo - will be cleaned up in send_message() message.blob = blob - message.is_draft = False - message.draft_blob = None message.created_at = timezone.now() message.updated_at = timezone.now() message.save( @@ -274,16 +271,13 @@ def prepare_outbound_message( "updated_at", "blob", "mime_id", - "is_draft", - "draft_blob", "created_at", ] ) message.thread.update_stats() - # Clean up the draft blob and the attachment blobs - if draft_blob: - draft_blob.delete() + # Clean up attachment blobs (but keep draft_blob for potential undo) + # The draft_blob will be cleaned up later in send_message() once actually sent for attachment in message.attachments.all(): if attachment.blob: attachment.blob.delete() @@ -298,9 +292,26 @@ def send_message(message: models.Message, force_mta_out: bool = False): This part is called asynchronously from the celery worker. """ - # Refuse to send messages that are draft or not senders + # Mark message as not draft (allows undo to keep as draft if cancelled) if message.is_draft: - raise ValueError("Cannot send a draft message") + logger.info(f"Message {message.id} is draft, marking as non-draft before sending") + message.is_draft = False + message.save(update_fields=["is_draft"]) + else: + logger.info(f"Message {message.id} is already non-draft, proceeding with send") + + # Clean up draft_blob now that message is being sent + # (it was preserved during prepare_outbound_message for potential undo) + if message.draft_blob: + try: + message.draft_blob.delete() + message.draft_blob = None + message.save(update_fields=["draft_blob"]) + logger.info(f"Cleaned up draft_blob for message {message.id} after send") + except Exception as e: + logger.warning(f"Failed to cleanup draft_blob for message {message.id}: {e}") + + # Refuse to send messages we are not sender of if not message.is_sender: raise ValueError("Cannot send a message we are not sender of") @@ -472,6 +483,8 @@ def _mark_delivered( finally: # Always release the lock when done cache.delete(lock_key) + # Update thread stats after send completes (for delayed sends) + message.thread.update_stats() def send_outbound_message( diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 2e7a03027..1fdcd9254 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -33,6 +33,7 @@ from core.api.viewsets.metrics import MailDomainUsersMetricsApiView from core.api.viewsets.placeholder import PlaceholderView from core.api.viewsets.send import SendMessageView +from core.api.viewsets.cancel_send import CancelSendView from core.api.viewsets.task import TaskDetailView from core.api.viewsets.thread import ThreadViewSet from core.api.viewsets.thread_access import ThreadAccessViewSet @@ -174,6 +175,11 @@ SendMessageView.as_view(), name="send-message", ), + path( + f"api/{settings.API_VERSION}/send/cancel/", + CancelSendView.as_view(), + name="cancel-send", + ), path( f"api/{settings.API_VERSION}/tasks//", TaskDetailView.as_view(), From 3b31a01784a9057efee0af59aaab148476897d81 Mon Sep 17 00:00:00 2001 From: Niels Kersic Date: Wed, 10 Dec 2025 15:11:50 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E2=8F=B3(frontend)=20implement=20undo=20se?= =?UTF-8?q?nd=20UI=20with=20countdown=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add UndoSendToast component with countdown timer - Integrate undo functionality in message send flow - Use refetchQueries to ensure fresh data before navigation - Clean up draft blob when canceling send --- src/backend/core/api/openapi.json | 111 ++++++++++++++++++ .../forms/components/message-form/index.tsx | 77 +++++++++++- .../components/thread-message/index.tsx | 2 + .../message/components/undo-send-toast.tsx | 48 ++++++++ .../src/features/message/use-cancel-send.tsx | 30 +++++ 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/features/message/components/undo-send-toast.tsx create mode 100644 src/frontend/src/features/message/use-cancel-send.tsx diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 4650b44cc..6288cf439 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -4133,6 +4133,91 @@ } } }, + "/api/v1.0/send/cancel/": { + "post": { + "operationId": "send_cancel_create", + "description": "\n Cancel a queued send task and keep the message as a draft.\n\n This endpoint revokes the Celery task responsible for sending the message\n and ensures the message remains in draft state.\n ", + "tags": [ + "messages" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelSendRequest" + }, + "examples": { + "CancelSend": { + "value": { + "taskId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "messageId": "123e4567-e89b-12d3-a456-426614174000" + }, + "summary": "Cancel Send" + } + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CancelSendRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified response body" + }, + "examples": { + "CancelSend": { + "value": { + "taskId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "messageId": "123e4567-e89b-12d3-a456-426614174000" + }, + "summary": "Cancel Send" + } + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified response body" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {}, + "description": "Unspecified response body" + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/tasks/{task_id}/": { "get": { "operationId": "tasks_retrieve", @@ -5119,6 +5204,26 @@ "type" ] }, + "CancelSendRequest": { + "type": "object", + "description": "Serializer for canceling send tasks.", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "description": "Celery task ID to cancel" + }, + "messageId": { + "type": "string", + "format": "uuid", + "description": "Message ID to keep as draft" + } + }, + "required": [ + "messageId", + "taskId" + ] + }, "ChangeFlagRequestRequest": { "type": "object", "properties": { @@ -7109,6 +7214,12 @@ }, "htmlBody": { "type": "string" + }, + "delay": { + "type": "integer", + "maximum": 30, + "minimum": 0, + "default": 0 } }, "required": [ diff --git a/src/frontend/src/features/forms/components/message-form/index.tsx b/src/frontend/src/features/forms/components/message-form/index.tsx index ab21d06ac..4888253a5 100644 --- a/src/frontend/src/features/forms/components/message-form/index.tsx +++ b/src/frontend/src/features/forms/components/message-form/index.tsx @@ -25,6 +25,8 @@ import i18n from "@/features/i18n/initI18n"; import { DropdownButton } from "@/features/ui/components/dropdown-button"; import { PREFER_SEND_MODE_KEY, PreferSendMode } from "@/features/config/constants"; import { useSearchParams } from "next/navigation"; +import { UndoSendToast } from "@/features/message/components/undo-send-toast"; +import { useCancelSend } from "@/features/message/use-cancel-send"; export type MessageFormMode = "new" | "reply" | "reply_all" | "forward"; @@ -116,6 +118,10 @@ export const MessageForm = ({ })?.id ?? mailboxes?.[0]?.id; const hideFromField = defaultSenderId && (mailboxes?.length ?? 0) === 1; const { addQueuedMessage } = useSentBox(); + const cancelSendMutation = useCancelSend(); + + // Hardcoded delay for Phase 1 testing (will be user setting in Phase 2) + const UNDO_SEND_DELAY = 5; const getMailboxOptions = () => { if (!mailboxes) return []; @@ -240,9 +246,75 @@ export const MessageForm = ({ onSuccess: async (response, { data: variables }) => { const data = (response as sendCreateResponse200).data; const taskId = data.task_id; + const message = data.message; const shouldCloseThread = !!variables.archive; - addQueuedMessage(taskId, shouldCloseThread); - onSuccess?.(); + const delay = variables.delay || 0; + + if (delay > 0) { + // Show undo toast with countdown + const undoToastId = `undo-send-${taskId}`; + + const handleUndo = async () => { + // Dismiss the toast immediately + toast.dismiss(undoToastId); + + // Cancel the send task first, then navigate after queries refetch + try { + // This will wait for both the cancel API call AND the query refetch to complete + await cancelSendMutation.mutateAsync({ + taskId, + messageId: message.id, + }); + + // Navigate to the draft + // Use window.location for now since router.push has issues with this navigation + if (message?.thread_id) { + const targetUrl = `/mailbox/${selectedMailbox?.id}/thread/${message.thread_id}?has_draft=1`; + window.location.href = targetUrl; + } + } catch (error) { + console.error('Failed to cancel send:', error); + } + }; + + const handleComplete = () => { + toast.dismiss(undoToastId); + // For delayed sends, show a simple success toast instead of using QueueMessage + // This avoids polling issues with delayed Celery tasks + addToast( + + check_circle + {t("Message sent successfully")} + , + { + autoClose: 2000, + } + ); + // Invalidate queries to refresh UI + if (shouldCloseThread) unselectThread(); + invalidateThreadsStats(); + invalidateThreadMessages(); + }; + + addToast( + , + { + toastId: undoToastId, + autoClose: false, + } + ); + + // Navigate to inbox immediately (like Gmail) + onSuccess?.(); + } else { + // Immediate send (delay = 0) - use QueueMessage for status tracking + addQueuedMessage(taskId, shouldCloseThread); + onSuccess?.(); + } } } }); @@ -441,6 +513,7 @@ export const MessageForm = ({ htmlBody: MailHelper.attachDriveAttachmentsToHtmlBody(data.messageHtmlBody, data.driveAttachments), textBody: MailHelper.attachDriveAttachmentsToTextBody(data.messageTextBody, data.driveAttachments), archive, + delay: UNDO_SEND_DELAY, } }); }; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx index 89eb91b00..463a2803a 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx @@ -20,6 +20,7 @@ import { ContactChip, ContactChipDeliveryStatus } from "@/features/ui/components import clsx from "clsx"; import { useThreadViewContext } from "../../provider"; import usePrevious from "@/hooks/use-previous"; +import { useSearchParams } from "next/navigation"; type ThreadMessageProps = { message: Message, @@ -30,6 +31,7 @@ type ThreadMessageProps = { export const ThreadMessage = forwardRef( ({ message, isLatest, draftMessage, ...props }, ref) => { const { t, i18n } = useTranslation() + const searchParams = useSearchParams() const getReplyFormMode = () => { if (draftMessage?.is_draft) return 'reply'; if (!message.is_draft || message.is_trashed) return null; diff --git a/src/frontend/src/features/message/components/undo-send-toast.tsx b/src/frontend/src/features/message/components/undo-send-toast.tsx new file mode 100644 index 000000000..3a920e570 --- /dev/null +++ b/src/frontend/src/features/message/components/undo-send-toast.tsx @@ -0,0 +1,48 @@ +import { ToasterItem } from "@/features/ui/components/toaster"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +type UndoSendToastProps = { + delay: number; + onUndo: () => void; + onComplete: () => void; +}; + +export const UndoSendToast = ({ delay, onUndo, onComplete }: UndoSendToastProps) => { + const { t } = useTranslation(); + const [countdown, setCountdown] = useState(delay); + const [isCancelled, setIsCancelled] = useState(false); + + useEffect(() => { + if (countdown <= 0 || isCancelled) { + return; + } + + const timer = setInterval(() => { + setCountdown((prev) => { + const next = prev - 1; + if (next <= 0) { + clearInterval(timer); + onComplete(); + } + return next; + }); + }, 1000); + + return () => clearInterval(timer); + }, [countdown, onComplete, isCancelled]); + + const handleUndo = () => { + setIsCancelled(true); + onUndo(); + }; + + return ( + + {t("Sending in {{seconds}} seconds...", { seconds: countdown })} + + ); +}; diff --git a/src/frontend/src/features/message/use-cancel-send.tsx b/src/frontend/src/features/message/use-cancel-send.tsx new file mode 100644 index 000000000..67023b67f --- /dev/null +++ b/src/frontend/src/features/message/use-cancel-send.tsx @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { fetchAPI } from "@/features/api/fetch-api"; + +type CancelSendParams = { + taskId: string; + messageId: string; +}; + +const cancelSend = async (params: CancelSendParams): Promise => { + await fetchAPI("/api/v1.0/send/cancel/", { + method: "POST", + body: JSON.stringify(params), + }); +}; + +export const useCancelSend = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: cancelSend, + onSuccess: async () => { + // Refetch queries and wait for them to complete + // This ensures the UI has fresh data before navigation + await Promise.all([ + queryClient.refetchQueries({ queryKey: ["messages"] }), + queryClient.refetchQueries({ queryKey: ["threads"] }), + ]); + }, + }); +}; From 9780fb7594dde4623a0bff87da3932fb65ce06de Mon Sep 17 00:00:00 2001 From: Niels Kersic Date: Wed, 10 Dec 2025 17:30:55 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=A7(backend)=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/api/viewsets/cancel_send.py | 30 +++++++++---------- src/backend/core/mda/outbound.py | 13 +------- .../forms/components/message-form/index.tsx | 10 ------- .../src/features/message/use-cancel-send.tsx | 2 -- 4 files changed, 15 insertions(+), 40 deletions(-) diff --git a/src/backend/core/api/viewsets/cancel_send.py b/src/backend/core/api/viewsets/cancel_send.py index 5b7cc6957..51fbf6303 100644 --- a/src/backend/core/api/viewsets/cancel_send.py +++ b/src/backend/core/api/viewsets/cancel_send.py @@ -2,7 +2,6 @@ import logging -from celery.result import AsyncResult from drf_spectacular.utils import OpenApiExample, extend_schema from rest_framework import exceptions as drf_exceptions from rest_framework import serializers as drf_serializers @@ -13,7 +12,7 @@ from core import models from core.mda.tasks import celery_app -from .. import permissions, serializers +from .. import permissions logger = logging.getLogger(__name__) @@ -71,17 +70,19 @@ def post(self, request): task_id = serializer.validated_data.get("taskId") message_id = serializer.validated_data.get("messageId") - # Revoke the Celery task - celery_app.control.revoke(task_id, terminate=True) - logger.info(f"Revoked Celery task {task_id} for message {message_id}") - # Ensure message stays as draft try: message = models.Message.objects.get(id=message_id) - # Ensure message is marked as draft - # Note: draft_blob is preserved during prepare_outbound_message(), - # so it should still exist and doesn't need restoration + # Check if already sent - if draft_blob is gone, it's too late + if not message.draft_blob: + raise drf_exceptions.ValidationError( + "Cannot cancel: message has already been sent." + ) + + celery_app.control.revoke(task_id, terminate=True) + logger.info(f"Revoked Celery task {task_id} for message {message_id}") + needs_save = False update_fields = [] @@ -90,8 +91,7 @@ def post(self, request): needs_save = True update_fields.append("is_draft") - # Remove the blob (MIME) if it exists - drafts shouldn't have a blob - # The blob was created by prepare_outbound_message() but we're cancelling + # Remove MIME blob - drafts shouldn't have one if message.blob: try: message.blob.delete() @@ -105,14 +105,12 @@ def post(self, request): if needs_save: message.save(update_fields=update_fields) - # Update thread stats since message state changed message.thread.update_stats() - logger.info(f"Message {message_id} reverted to draft state after send cancellation") + logger.info(f"Message {message_id} reverted to draft state") else: - logger.info(f"Message {message_id} was already in correct draft state") + logger.info(f"Message {message_id} was already in draft state") except models.Message.DoesNotExist as e: - # Message might have been deleted, but task was revoked anyway - logger.warning(f"Message {message_id} not found during cancel, but task {task_id} was revoked") + logger.warning(f"Message {message_id} not found, but task {task_id} was revoked") raise drf_exceptions.NotFound("Message not found.") from e return Response( diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index 7710ea5ad..243b6fed4 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -262,7 +262,7 @@ def prepare_outbound_message( content_type="message/rfc822", ) - # Keep draft_blob reference for potential undo - will be cleaned up in send_message() + # Preserve draft_blob for undo functionality (cleaned up in send_message) message.blob = blob message.created_at = timezone.now() message.updated_at = timezone.now() @@ -276,8 +276,6 @@ def prepare_outbound_message( ) message.thread.update_stats() - # Clean up attachment blobs (but keep draft_blob for potential undo) - # The draft_blob will be cleaned up later in send_message() once actually sent for attachment in message.attachments.all(): if attachment.blob: attachment.blob.delete() @@ -292,22 +290,15 @@ def send_message(message: models.Message, force_mta_out: bool = False): This part is called asynchronously from the celery worker. """ - # Mark message as not draft (allows undo to keep as draft if cancelled) if message.is_draft: - logger.info(f"Message {message.id} is draft, marking as non-draft before sending") message.is_draft = False message.save(update_fields=["is_draft"]) - else: - logger.info(f"Message {message.id} is already non-draft, proceeding with send") - # Clean up draft_blob now that message is being sent - # (it was preserved during prepare_outbound_message for potential undo) if message.draft_blob: try: message.draft_blob.delete() message.draft_blob = None message.save(update_fields=["draft_blob"]) - logger.info(f"Cleaned up draft_blob for message {message.id} after send") except Exception as e: logger.warning(f"Failed to cleanup draft_blob for message {message.id}: {e}") @@ -481,9 +472,7 @@ def _mark_delivered( True, ) finally: - # Always release the lock when done cache.delete(lock_key) - # Update thread stats after send completes (for delayed sends) message.thread.update_stats() diff --git a/src/frontend/src/features/forms/components/message-form/index.tsx b/src/frontend/src/features/forms/components/message-form/index.tsx index 4888253a5..e83fe2043 100644 --- a/src/frontend/src/features/forms/components/message-form/index.tsx +++ b/src/frontend/src/features/forms/components/message-form/index.tsx @@ -255,19 +255,14 @@ export const MessageForm = ({ const undoToastId = `undo-send-${taskId}`; const handleUndo = async () => { - // Dismiss the toast immediately toast.dismiss(undoToastId); - // Cancel the send task first, then navigate after queries refetch try { - // This will wait for both the cancel API call AND the query refetch to complete await cancelSendMutation.mutateAsync({ taskId, messageId: message.id, }); - // Navigate to the draft - // Use window.location for now since router.push has issues with this navigation if (message?.thread_id) { const targetUrl = `/mailbox/${selectedMailbox?.id}/thread/${message.thread_id}?has_draft=1`; window.location.href = targetUrl; @@ -279,8 +274,6 @@ export const MessageForm = ({ const handleComplete = () => { toast.dismiss(undoToastId); - // For delayed sends, show a simple success toast instead of using QueueMessage - // This avoids polling issues with delayed Celery tasks addToast( check_circle @@ -290,7 +283,6 @@ export const MessageForm = ({ autoClose: 2000, } ); - // Invalidate queries to refresh UI if (shouldCloseThread) unselectThread(); invalidateThreadsStats(); invalidateThreadMessages(); @@ -308,10 +300,8 @@ export const MessageForm = ({ } ); - // Navigate to inbox immediately (like Gmail) onSuccess?.(); } else { - // Immediate send (delay = 0) - use QueueMessage for status tracking addQueuedMessage(taskId, shouldCloseThread); onSuccess?.(); } diff --git a/src/frontend/src/features/message/use-cancel-send.tsx b/src/frontend/src/features/message/use-cancel-send.tsx index 67023b67f..d2cfd18ee 100644 --- a/src/frontend/src/features/message/use-cancel-send.tsx +++ b/src/frontend/src/features/message/use-cancel-send.tsx @@ -19,8 +19,6 @@ export const useCancelSend = () => { return useMutation({ mutationFn: cancelSend, onSuccess: async () => { - // Refetch queries and wait for them to complete - // This ensures the UI has fresh data before navigation await Promise.all([ queryClient.refetchQueries({ queryKey: ["messages"] }), queryClient.refetchQueries({ queryKey: ["threads"] }),