Skip to content

Conversation

@NielsCodes
Copy link
Contributor

@NielsCodes NielsCodes commented Dec 10, 2025

Purpose

Add "undo" option that allows users to cancel sending an email within 5 seconds.

Proposal

Screenshot 2025-12-10 at 17 48 08

Instead of immediately sending an email, the email is sent to Celery with a delay.
The user will see a toast with a 5-second countdown and an "Undo" button.

When the user clicks "Undo", the Celery task is cancelled and the email is set back to Draft.
The user is redirected to the Drafts page, where the email is opened in the draft editor.

Summary by CodeRabbit

  • New Features
    • Scheduled sending: users can delay sending messages up to 30 seconds.
    • Undo send: queued messages show a visible countdown toast allowing cancellation and restoration to draft.
    • Cancel send endpoint: queued sends can be canceled so the message remains editable as a draft.

✏️ Tip: You can customize this high-level summary in your review settings.

Add new API endpoint to cancel queued send tasks and revert messages
to draft state. This enables the undo send feature.
- 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
@coderabbitai
Copy link

coderabbitai bot commented Dec 10, 2025

Walkthrough

Adds a delayed-send + undo workflow: new POST /api/v1.0/send/cancel/ endpoint and CancelSendRequest schema, a delay field (0–30) on send requests, backend changes to schedule/revoke Celery send tasks and preserve drafts, frontend undo-toast and cancel hook wired into the send flow.

Changes

Cohort / File(s) Summary
API Spec & Serializers
src/backend/core/api/openapi.json, src/backend/core/api/serializers.py
Added POST /api/v1.0/send/cancel/ with CancelSendRequest (taskId, messageId). Added delay (int 0–30, default 0) to SendMessageRequest and to SendMessageSerializer fields/validation.
Cancel endpoint & Send view
src/backend/core/api/viewsets/cancel_send.py, src/backend/core/api/viewsets/send.py
New CancelSendView and CancelSendSerializer to revoke Celery tasks and keep messages as drafts; send view updated to schedule tasks when delay>0, keep drafts when delayed, and return serialized message + task_id.
Outbound processing
src/backend/core/mda/outbound.py
prepare_outbound_message now preserves draft_blob; send_message finalizes drafts when sending, deletes draft blob with tolerant errors, adds per-message lock to avoid concurrent sends, and consolidates thread stats updates in finally.
Routing
src/backend/core/urls.py
Imported and registered CancelSendView at api/{version}/send/cancel/.
Frontend send flow
src/frontend/src/features/forms/components/message-form/index.tsx, src/frontend/src/features/message/use-cancel-send.tsx
Message form sends delay (hardcoded undo delay = 5s), shows UndoSendToast when delayed, uses useCancelSend to POST cancel and refetch queries; preserves immediate-send path for delay==0.
Frontend UI & minor edits
src/frontend/src/features/message/components/undo-send-toast.tsx, src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx
Added UndoSendToast component (countdown + Undo). Added unused useSearchParams import in thread-message.

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend
    participant Backend
    participant Queue as Celery
    participant DB as Database

    User->>Frontend: Submit send (delay=5)
    Frontend->>Backend: POST /api/v1.0/send (payload includes delay)
    Backend->>DB: Save message (is_draft=true)
    Backend->>Queue: Enqueue send task with countdown(5)
    Backend-->>Frontend: 200 { task_id, message }

    Frontend->>Frontend: Show UndoSendToast (countdown)

    alt User clicks Undo before timeout
        User->>Frontend: Click Undo
        Frontend->>Backend: POST /api/v1.0/send/cancel/ (taskId, messageId)
        Backend->>Queue: Revoke task (terminate=true)
        Backend->>DB: Keep message as draft, remove draft_blob (if present)
        Backend-->>Frontend: 200 OK
        Frontend->>Frontend: Navigate to draft / update UI
    else Countdown completes
        Queue->>Backend: Execute scheduled send task
        Backend->>DB: Finalize draft -> is_draft=false, cleanup draft_blob
        Backend->>DB: Update thread stats
        Backend-->>Frontend: (after fetch) reflect sent state
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20–30 minutes

  • Review focus:
    • Distributed send lock logic and its correctness in src/backend/core/mda/outbound.py
    • Draft preservation and cleanup paths split between prepare_outbound_message, send_message, and cancel view
    • Celery revoke semantics and error handling in src/backend/core/api/viewsets/cancel_send.py
    • Frontend undo flow: timing, cancellation race conditions, and query refetches in src/frontend/.../use-cancel-send.tsx and message-form changes
    • Ensure OpenAPI/spec and serializer delay field are consistent

Poem

🐰 I held a message on a string,
Five ticks to think, five ticks to cling.
Tap "Undo" and watch the thread,
Back to drafts — no words unsaid.
Hop, cancel, breathe — then let it sing.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title 'Undo send' accurately reflects the main feature being implemented—adding undo functionality for sending emails with a 5-second delay window.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (11)
src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx (1)

23-23: Verify the intent of the unused searchParams variable.

The useSearchParams hook is imported and initialized but the searchParams variable is never used in the component. Given this PR adds an undo-send feature that redirects users to the Drafts page and opens the draft editor, it's possible this was intended to handle query-driven navigation (e.g., opening a specific draft based on URL parameters) but the implementation was not completed.

Please clarify:

  1. Is this preparatory code for upcoming functionality in this draft PR?
  2. Should this be removed as unused code?
  3. Was there intended usage that was forgotten?

If this is incomplete work, consider either completing the implementation or removing the unused import and variable to keep the code clean.

Also applies to: 34-34

src/backend/core/mda/outbound.py (1)

304-304: Use lazy string formatting for logging.

Per coding guidelines, prefer %-style formatting in logging calls for lazy evaluation:

-            logger.warning(f"Failed to cleanup draft_blob for message {message.id}: {e}")
+            logger.warning("Failed to cleanup draft_blob for message %s: %s", message.id, e)
src/frontend/src/features/message/use-cancel-send.tsx (2)

19-29: Consider adding error handling to the mutation.

The mutation lacks an onError callback. While errors will propagate when using mutateAsync, consumers using mutate won't have error feedback. Consider adding error handling:

     return useMutation({
         mutationFn: cancelSend,
         onSuccess: async () => {
             await Promise.all([
                 queryClient.refetchQueries({ queryKey: ["messages"] }),
                 queryClient.refetchQueries({ queryKey: ["threads"] }),
             ]);
         },
+        onError: (error) => {
+            console.error('Failed to cancel send:', error);
+        },
     });

9-14: Return type should match API response.

The cancelSend function returns Promise<void>, but fetchAPI returns a response object. Consider typing the return value to ensure type safety:

-const cancelSend = async (params: CancelSendParams): Promise<void> => {
-    await fetchAPI("/api/v1.0/send/cancel/", {
+const cancelSend = async (params: CancelSendParams) => {
+    return fetchAPI<{ status: number; data: { detail: string } }>("/api/v1.0/send/cancel/", {
         method: "POST",
         body: JSON.stringify(params),
     });
 };
src/frontend/src/features/message/components/undo-send-toast.tsx (1)

16-33: useEffect recreates interval on every countdown change.

Including countdown in the dependency array causes the effect to re-run every second, unnecessarily recreating the interval. Also, calling onComplete() inside setCountdown is a side effect in a state updater.

Consider using a ref to track the interval and only run the effect once:

+    const timerRef = useRef<NodeJS.Timeout | null>(null);
+
     useEffect(() => {
-        if (countdown <= 0 || isCancelled) {
+        if (isCancelled) {
             return;
         }

-        const timer = setInterval(() => {
+        timerRef.current = setInterval(() => {
             setCountdown((prev) => {
-                const next = prev - 1;
-                if (next <= 0) {
-                    clearInterval(timer);
-                    onComplete();
-                }
-                return next;
+                return prev - 1;
             });
         }, 1000);

-        return () => clearInterval(timer);
-    }, [countdown, onComplete, isCancelled]);
+        return () => {
+            if (timerRef.current) clearInterval(timerRef.current);
+        };
+    }, [isCancelled]);
+
+    useEffect(() => {
+        if (countdown <= 0 && !isCancelled) {
+            if (timerRef.current) clearInterval(timerRef.current);
+            onComplete();
+        }
+    }, [countdown, isCancelled, onComplete]);
src/frontend/src/features/forms/components/message-form/index.tsx (2)

123-124: Move UNDO_SEND_DELAY to a config constant.

The comment indicates this will become a user setting in Phase 2. Consider moving this to a centralized config file now to make the future change easier:

-    // Hardcoded delay for Phase 1 testing (will be user setting in Phase 2)
-    const UNDO_SEND_DELAY = 5;
+    // TODO: Make this a user setting in Phase 2

And in a config file (e.g., src/features/config/constants.ts):

export const UNDO_SEND_DELAY = 5;

270-274: Document the router.push workaround or create a follow-up issue.

Using window.location.href causes a full page reload, which degrades UX. The comment mentions "router.push has issues" - consider documenting the specific issue or creating a follow-up task to fix it properly.

src/backend/core/api/viewsets/cancel_send.py (1)

20-25: Consider validating taskId format.

While Celery will ignore invalid task IDs, you might want to use UUIDField for taskId since Celery task IDs are typically UUIDs. This would provide earlier validation and clearer API documentation.

-    taskId = drf_serializers.CharField(required=True, help_text="Celery task ID to cancel")
+    taskId = drf_serializers.UUIDField(required=True, help_text="Celery task ID to cancel")
src/backend/core/api/openapi.json (3)

4136-4220: Define a concrete response and cover “already started” case.

Return a typed payload instead of an unspecified object, and add 409 when the task can’t be revoked (already STARTED/SUCCESS). Also declare idempotency (repeated cancels should be OK and return the same shape).

Apply this diff in the cancel path to use a proper schema and add 409:

       "responses": {
         "200": {
           "content": {
             "application/json": {
-              "schema": {
-                "type": "object",
-                "additionalProperties": {},
-                "description": "Unspecified response body"
-              },
+              "schema": { "$ref": "#/components/schemas/CancelSendResponse" },
               "examples": {
                 "CancelSend": {
                   "value": {
                     "taskId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
                     "messageId": "123e4567-e89b-12d3-a456-426614174000"
                   },
                   "summary": "Cancel Send"
                 }
               }
             }
           },
           "description": ""
         },
+        "409": {
+          "content": {
+            "application/json": {
+              "schema": { "type": "object", "properties": { "detail": { "type": "string" } } }
+            }
+          },
+          "description": "Task already started or finished; cannot cancel"
+        },
         "400": {

And add this schema under components.schemas:

+      "CancelSendResponse": {
+        "type": "object",
+        "properties": {
+          "taskId": { "type": "string", "description": "Celery task ID revoked" },
+          "messageId": { "type": "string", "format": "uuid", "description": "Draft message ID" },
+          "revoked": { "type": "boolean", "description": "True if revoke issued (idempotent)" }
+        },
+        "required": ["taskId", "messageId", "revoked"]
+      },

5207-5226: Tighten taskId typing and align naming.

If your Celery task IDs are UUIDs, declare format: uuid; otherwise document the expected format. Also consider harmonizing taskId vs task_id used elsewhere for consistency.

       "CancelSendRequest": {
         "type": "object",
         "description": "Serializer for canceling send tasks.",
         "properties": {
           "taskId": {
-            "type": "string",
+            "type": "string",
+            "format": "uuid",
             "minLength": 1,
             "description": "Celery task ID to cancel"
           },

Please confirm whether Celery task IDs can be non‑UUID in your deployment; if yes, keep type: string and add a brief note like “UUID by default; may be broker-specific”.


7218-7223: Document delay clearly; verify default matches product behavior.

Add a short description clarifying units and undo semantics. If the intended default undo window is 5s, consider updating the backend serializer default and then this schema.

           "delay": {
             "type": "integer",
             "maximum": 30,
             "minimum": 0,
-            "default": 0
+            "default": 0,
+            "description": "Delay in seconds before dispatch (enables undo-cancel within this window)"
           }

Confirm the server-side default for delay in SendMessageSerializer; if you change it to 5s to match the UX, update the OpenAPI default here too.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3b6f0b9 and 5cc053f.

📒 Files selected for processing (10)
  • src/backend/core/api/openapi.json (3 hunks)
  • src/backend/core/api/serializers.py (1 hunks)
  • src/backend/core/api/viewsets/cancel_send.py (1 hunks)
  • src/backend/core/api/viewsets/send.py (2 hunks)
  • src/backend/core/mda/outbound.py (3 hunks)
  • src/backend/core/urls.py (2 hunks)
  • src/frontend/src/features/forms/components/message-form/index.tsx (4 hunks)
  • src/frontend/src/features/layouts/components/thread-view/components/thread-message/index.tsx (2 hunks)
  • src/frontend/src/features/message/components/undo-send-toast.tsx (1 hunks)
  • src/frontend/src/features/message/use-cancel-send.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/send.py
  • src/backend/core/api/viewsets/cancel_send.py
  • src/backend/core/mda/outbound.py
  • src/backend/core/urls.py
src/backend/{templates/**/*.html,**/serializers.py}

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Use Django templates for HTML and DRF serializers for JSON responses

Files:

  • src/backend/core/api/serializers.py
src/backend/**/urls.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

Define clear, RESTful URL patterns using Django’s URL dispatcher

Files:

  • src/backend/core/urls.py
🧠 Learnings (3)
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/{views.py,tasks.py} : Use asynchronous views and Celery tasks for I/O-bound or long-running operations

Applied to files:

  • src/backend/core/api/viewsets/cancel_send.py
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/views.py : Use Django REST Framework viewsets for API endpoints

Applied to files:

  • src/backend/core/api/viewsets/cancel_send.py
  • src/backend/core/urls.py
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/urls.py : Define clear, RESTful URL patterns using Django’s URL dispatcher

Applied to files:

  • src/backend/core/urls.py
🧬 Code graph analysis (6)
src/backend/core/api/viewsets/send.py (2)
src/backend/core/mda/tasks.py (1)
  • send_message_task (21-78)
src/backend/core/api/serializers.py (1)
  • MessageSerializer (619-780)
src/frontend/src/features/message/use-cancel-send.tsx (1)
src/frontend/src/features/api/fetch-api.ts (1)
  • fetchAPI (12-40)
src/backend/core/api/viewsets/cancel_send.py (3)
src/backend/core/api/permissions.py (1)
  • IsAllowedToAccess (92-167)
src/backend/core/api/viewsets/send.py (1)
  • post (77-151)
src/backend/core/models.py (1)
  • update_stats (769-895)
src/frontend/src/features/message/components/undo-send-toast.tsx (1)
src/frontend/src/features/ui/components/toaster/index.tsx (1)
  • ToasterItem (16-61)
src/backend/core/mda/outbound.py (1)
src/backend/core/models.py (7)
  • save (92-95)
  • save (207-210)
  • save (291-294)
  • save (954-1006)
  • save (1501-1504)
  • save (1757-1773)
  • update_stats (769-895)
src/backend/core/urls.py (1)
src/backend/core/api/viewsets/cancel_send.py (1)
  • CancelSendView (60-120)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: docker-publish-socks-proxy / docker-build-push
  • GitHub Check: lint-back
  • GitHub Check: lint-front
  • GitHub Check: test-front
  • GitHub Check: test-e2e
  • GitHub Check: build-front
  • GitHub Check: test-back
  • GitHub Check: check-api-state
🔇 Additional comments (9)
src/backend/core/mda/outbound.py (2)

475-477: Good placement of thread stats update in finally block.

Moving update_stats() to the finally block ensures thread statistics remain consistent regardless of delivery success or failure.


294-304: [rewritten comment]
[classification tag]

src/backend/core/urls.py (1)

36-36: LGTM!

The new cancel-send endpoint follows established URL patterns and naming conventions. The route is logically placed alongside the send endpoint. Based on learnings, this follows the guideline for clear, RESTful URL patterns.

Also applies to: 178-182

src/backend/core/api/viewsets/send.py (2)

124-135: LGTM - Delayed send branching is correctly implemented.

The logic properly distinguishes between delayed and immediate sends:

  • Delayed sends (delay > 0): Keep message as draft, schedule task with countdown
  • Immediate sends (delay == 0): Mark as non-draft synchronously, then queue task

This ensures the message state is appropriate for each scenario and supports the undo workflow.


141-143: Correct conditional stats update.

Thread stats are only updated for immediate sends here. For delayed sends, update_stats() is appropriately called in send_message() (outbound.py) within the finally block after task execution.

src/frontend/src/features/forms/components/message-form/index.tsx (1)

253-317: Well-structured delayed send flow with proper cleanup.

The undo flow correctly:

  • Dismisses the toast immediately on undo action
  • Waits for cancel mutation and query refetch before navigation
  • Shows success toast on completion
  • Invalidates relevant queries to refresh UI state

Good implementation of the feature requirements.

src/backend/core/api/serializers.py (1)

1463-1466: LGTM! The delay field is well-defined.

The field correctly supports the delayed-send feature with appropriate validation (0-30 seconds) and a backwards-compatible default of 0 for immediate sends.

src/backend/core/api/viewsets/cancel_send.py (2)

1-18: LGTM! Imports are appropriate.

The imports are well-organized and include all necessary dependencies for the cancel-send functionality.


27-59: LGTM! OpenAPI documentation is comprehensive.

The schema decoration provides clear examples and response formats that will help API consumers understand the endpoint.

Comment on lines +65 to +82
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")

# Ensure message stays as draft
try:
message = models.Message.objects.get(id=message_id)

# 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."
)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing object-level permission check.

The view fetches the message but never validates that the requesting user has permission to cancel it. Looking at the send view (src/backend/core/api/viewsets/send.py line 103), it calls self.check_object_permissions(request, message) after fetching.

Add permission check after fetching the message:

         try:
             message = models.Message.objects.get(id=message_id)
+            self.check_object_permissions(request, message)
 
             # Check if already sent - if draft_blob is gone, it's too late
             if not message.draft_blob:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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")
# Ensure message stays as draft
try:
message = models.Message.objects.get(id=message_id)
# 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."
)
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")
# Ensure message stays as draft
try:
message = models.Message.objects.get(id=message_id)
self.check_object_permissions(request, message)
# 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."
)
🤖 Prompt for AI Agents
In src/backend/core/api/viewsets/cancel_send.py around lines 65 to 82, after
fetching the message object you must perform an object-level permission check;
add a call to self.check_object_permissions(request, message) immediately after
message = models.Message.objects.get(id=message_id) so the view enforces the
same permissions as the send view before proceeding to validate draft_blob and
cancel.

Comment on lines 73 to 84
# Ensure message stays as draft
try:
message = models.Message.objects.get(id=message_id)

# 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."
)

# Revoke the Celery task
celery_app.control.revoke(task_id, terminate=True)
logger.info(f"Revoked Celery task {task_id} for message {message_id}")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition: message could be sent between check and revoke.

There's a time-of-check-to-time-of-use (TOCTOU) issue between verifying draft_blob exists (line 78) and revoking the task (line 84). If the Celery task executes in this window, the message will be sent, draft_blob removed, and then this code will attempt to revert it to draft state, leading to inconsistent data.

Consider implementing a distributed lock (e.g., using Redis) around the send and cancel operations to prevent concurrent execution:

from django.core.cache import cache

# In cancel view, before line 75
lock_key = f"message_send_lock_{message_id}"
if not cache.add(lock_key, "locked", timeout=60):
    raise drf_exceptions.ValidationError("Operation in progress")
try:
    # ... existing cancel logic ...
finally:
    cache.delete(lock_key)

And similarly in the send view's task execution. Based on learnings, this aligns with handling TOCTOU issues in concurrent operations.

🤖 Prompt for AI Agents
In src/backend/core/api/viewsets/cancel_send.py around lines 73 to 85, there is
a TOCTOU race between checking message.draft_blob and revoking the Celery task;
wrap the check+revoke in a distributed lock (e.g., Redis via Django cache) keyed
by the message id so cancel and the send task cannot run concurrently, acquire
the lock with a non-blocking add and return a ValidationError if unable to
acquire, perform the get/check/revoke logic while holding the lock, and always
release the lock in a finally block; ensure the send task also obtains/releases
the same lock so both sides coordinate and set a sensible lock timeout.

)

# Revoke the Celery task
celery_app.control.revoke(task_id, terminate=True)
Copy link

@coderabbitai coderabbitai bot Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Task revocation with terminate=True could disrupt email sending.

Using terminate=True sends SIGTERM to the worker, which forcefully kills the task even if it's mid-execution. If the task has already started sending the email (SMTP connection open, partial transmission), this could leave the system in an inconsistent state or cause email corruption.

Consider:

  1. First try revoking without terminate: celery_app.control.revoke(task_id, terminate=False)
  2. Check if the message state has changed (refresh from DB and re-check draft_blob)
  3. Only use terminate=True as a last resort if the message is still in a safe state

Alternatively, design the Celery task to check a cancellation flag at the start of execution and abort if set, avoiding the need for terminate=True.

🤖 Prompt for AI Agents
In src/backend/core/api/viewsets/cancel_send.py around line 84, the code
currently calls celery_app.control.revoke(task_id, terminate=True) which
forcefully kills tasks; update the flow to first call revoke with
terminate=False, then reload the message record from the database and re-check
draft_blob/state to determine whether the send has begun; only if the refreshed
state indicates it is still safe to forcibly stop (or if a
timeout/failed-soft-revoke occurs) call revoke with terminate=True as a last
resort; additionally, modify the task contract to respect a cancellable flag
(check a DB cancellation field at start and abort cleanly) so terminate=True is
rarely needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with coderabbit here, we need to cancel from the task, not from the outside

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sylvinus Understood! You're right — canceling from within the task is much safer and more predictable than external termination.

Here's the recommended approach:

Implementation Strategy

1. Add a cancellation flag to the Message model

Add a field to track cancellation intent:

# In models.py
is_send_cancelled = models.BooleanField(default=False)

2. Modify the cancel_send view

Instead of revoking the task, set the cancellation flag:

# In cancel_send.py
def post(self, request):
    # ... validation ...
    
    message = models.Message.objects.get(id=message_id)
    
    if not message.draft_blob:
        raise drf_exceptions.ValidationError(
            "Cannot cancel: message has already been sent."
        )
    
    # Set cancellation flag instead of revoking
    message.is_send_cancelled = True
    message.save(update_fields=["is_send_cancelled"])
    
    # Optional: still call revoke without terminate for cleanup
    celery_app.control.revoke(task_id, terminate=False)
    
    return Response({"detail": "Send cancelled successfully."})

3. Check the flag at the start of the Celery task

In send_message task (or wrapper), check before any SMTP operations:

# In tasks.py or outbound.py
@celery_app.task
def send_message_task(message_id, ...):
    message = Message.objects.get(id=message_id)
    
    # Check cancellation flag BEFORE any send operations
    if message.is_send_cancelled:
        logger.info(f"Send cancelled for message {message_id}, keeping as draft")
        # Clean up: reset the flag for potential future sends
        message.is_send_cancelled = False
        message.save(update_fields=["is_send_cancelled"])
        return  # Abort cleanly
    
    # Proceed with normal send flow
    send_message(message, ...)

This approach ensures:

  • ✅ No risk of interrupting mid-send
  • ✅ Clean state management
  • ✅ Task checks cancellation before touching SMTP
  • ✅ Predictable behavior with proper logging

Would you like me to help draft the complete implementation with these changes?

Comment on lines +275 to +273
} catch (error) {
console.error('Failed to cancel send:', error);
}
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Show user-facing error when cancel fails.

Currently, cancel failures are only logged to console. Users should see feedback when the undo operation fails:

                         } catch (error) {
                             console.error('Failed to cancel send:', error);
+                            addToast(
+                                <ToasterItem type="error">
+                                    <span>{t("Failed to cancel send. The message may have already been sent.")}</span>
+                                </ToasterItem>
+                            );
                         }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/frontend/src/features/forms/components/message-form/index.tsx around
lines 275 to 278, the catch block for the cancel send operation only logs to
console and does not surface any user-facing feedback; modify the handler to
present an error notification to the user (e.g., call the app's
toast/notification API or set component error state and render an inline
message) with a clear message like "Failed to undo send" and include the error
details for support; ensure the notification path is used consistently with
other error UX in this component and keep the console.error for debugging.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
src/backend/core/api/viewsets/cancel_send.py (3)

65-82: Missing object-level permission check.

The view fetches the message but never validates that the requesting user has permission to cancel it. Without this check, a user could potentially cancel another user's message.

Add permission check after fetching the message:

         try:
             message = models.Message.objects.get(id=message_id)
+            self.check_object_permissions(request, message)
 
             # Check if already sent - if draft_blob is gone, it's too late
             if not message.draft_blob:

73-84: Race condition: message could be sent between check and revoke.

There's a TOCTOU issue between verifying draft_blob exists (line 78) and revoking the task (line 83). If the Celery task executes in this window, the message will be sent, draft_blob removed, and then this code will attempt to revert it to draft state, leading to inconsistent data.

Consider implementing a distributed lock (e.g., using Redis via Django cache) around the send and cancel operations:

from django.core.cache import cache

# Before line 75
lock_key = f"message_send_lock_{message_id}"
if not cache.add(lock_key, "locked", timeout=60):
    raise drf_exceptions.ValidationError("Operation in progress")
try:
    # ... existing cancel logic ...
finally:
    cache.delete(lock_key)

Ensure the send task also acquires/releases the same lock.


83-84: Task revocation with terminate=True could disrupt email sending.

Using terminate=True sends SIGTERM to the worker, forcefully killing the task even if mid-execution. If the task has started sending (SMTP connection open, partial transmission), this could leave the system in an inconsistent state.

Consider:

  1. First try revoking without terminate: celery_app.control.revoke(task_id, terminate=False)
  2. Refresh the message from DB and re-check draft_blob to verify state
  3. Only use terminate=True as a last resort if still safe

Alternatively, design the Celery task to check a cancellation flag at the start and abort cleanly.

🧹 Nitpick comments (1)
src/backend/core/api/viewsets/cancel_send.py (1)

65-119: Consider async view for I/O-bound operations.

This view performs multiple I/O operations (database queries, Celery task revocation, blob deletion). Based on learnings, async views are recommended for I/O-bound operations to improve responsiveness.

Consider converting to an async view:

async def post(self, request):
    """Cancel a queued send task."""
    serializer = CancelSendSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    
    # Use async database queries
    message = await models.Message.objects.aget(id=message_id)
    # ... rest of logic with await for I/O operations

Based on learnings, this applies to I/O-bound operations in Django views.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5cc053f and 9780fb7.

📒 Files selected for processing (4)
  • src/backend/core/api/viewsets/cancel_send.py (1 hunks)
  • src/backend/core/mda/outbound.py (3 hunks)
  • src/frontend/src/features/forms/components/message-form/index.tsx (4 hunks)
  • src/frontend/src/features/message/use-cancel-send.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/frontend/src/features/message/use-cancel-send.tsx
  • src/backend/core/mda/outbound.py
  • src/frontend/src/features/forms/components/message-form/index.tsx
🧰 Additional context used
📓 Path-based instructions (1)
src/backend/**/*.py

📄 CodeRabbit inference engine (.cursor/rules/django-python.mdc)

src/backend/**/*.py: Follow Django/PEP 8 style with a 100-character line limit
Use descriptive, snake_case names for variables and functions
Use Django ORM for database access; avoid raw SQL unless necessary for performance
Use Django’s built-in user model and authentication framework
Prefer try-except blocks to handle exceptions in business logic and views
Log expected and unexpected actions with appropriate log levels
Capture and report exceptions to Sentry; use capture_exception() for custom errors
Do not log sensitive information (tokens, passwords, financial/health data, PII)

Files:

  • src/backend/core/api/viewsets/cancel_send.py
🧠 Learnings (1)
📚 Learning: 2025-09-02T10:12:12.835Z
Learnt from: CR
Repo: suitenumerique/messages PR: 0
File: .cursor/rules/django-python.mdc:0-0
Timestamp: 2025-09-02T10:12:12.835Z
Learning: Applies to src/backend/**/{views.py,tasks.py} : Use asynchronous views and Celery tasks for I/O-bound or long-running operations

Applied to files:

  • src/backend/core/api/viewsets/cancel_send.py
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: docker-publish-socks-proxy / docker-build-push
  • GitHub Check: check-api-state
  • GitHub Check: test-front
  • GitHub Check: build-front
  • GitHub Check: lint-back
  • GitHub Check: test-back
  • GitHub Check: lint-front
  • GitHub Check: test-e2e
🔇 Additional comments (3)
src/backend/core/api/viewsets/cancel_send.py (3)

20-25: LGTM: Serializer validation is correct.

The serializer properly validates both required fields with appropriate types and help text.


27-59: LGTM: Comprehensive API documentation.

The OpenAPI schema provides clear documentation with appropriate response examples and request payload structure.


95-105: LGTM: Robust blob deletion handling.

The code appropriately checks for blob existence, handles deletion failures gracefully with logging, and continues execution. The error handling ensures the operation isn't aborted if blob deletion fails.

Comment on lines +106 to +109
if needs_save:
message.save(update_fields=update_fields)
message.thread.update_stats()
logger.info(f"Message {message_id} reverted to draft state")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential AttributeError if message.thread is None.

Line 108 calls message.thread.update_stats() without verifying that thread is not None. If the foreign key allows null values, this will raise an AttributeError.

Add a null check before calling thread methods:

             if needs_save:
                 message.save(update_fields=update_fields)
-                message.thread.update_stats()
+                if message.thread:
+                    message.thread.update_stats()
                 logger.info(f"Message {message_id} reverted to draft state")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if needs_save:
message.save(update_fields=update_fields)
message.thread.update_stats()
logger.info(f"Message {message_id} reverted to draft state")
if needs_save:
message.save(update_fields=update_fields)
if message.thread:
message.thread.update_stats()
logger.info(f"Message {message_id} reverted to draft state")
🤖 Prompt for AI Agents
In src/backend/core/api/viewsets/cancel_send.py around lines 106 to 109, calling
message.thread.update_stats() can raise AttributeError if message.thread is
None; add a null check before invoking thread methods (for example, if
message.thread is not None: message.thread.update_stats()) so update_stats is
only called when a related thread exists, preserving the existing
save/update_fields and log behavior.

@NielsCodes
Copy link
Contributor Author

@sylvinus two things I'd like to get your thoughts on:

Delay setting
I think it makes sense to have the delay as a user setting like in Gmail, with the user being able to choose between 'no delay' and a few different delays. We don't really have a user settings page for this yet, so I defaulted to 5 seconds now. What do you think about this?

Brute force redirect
When the user clicks "undo" to cancel sending the email, the email is changed back to draft and the user is redirected to drafts page with the editor open. I needed to use window.location.href for this, which is not ideal.

When using router.push(), the app will redirect to the drafts page, but it will never open the thread itself. I noticed this is already an "issue" in the app, so I chose to not try to fix it on this branch. Do you think it's acceptable to use the hard redirect for now?

To check the issue with opening a draft in the current application:

  1. Go to Drafts
  2. Open a thread
  3. Navigate to another page in the app (to Inbox or Sent)
  4. Navigate back with the browser back button (or mouse buttons)
  5. --> The app will not open the thread we had open before, instead it will only navigate to the root Drafts page

@NielsCodes NielsCodes changed the title Draft: Undo send Undo send Dec 10, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants