Skip to content

✨(jmap) add JMAP endpoint#479

Draft
sylvinus wants to merge 3 commits intomainfrom
jmap
Draft

✨(jmap) add JMAP endpoint#479
sylvinus wants to merge 3 commits intomainfrom
jmap

Conversation

@sylvinus
Copy link
Copy Markdown
Member

@sylvinus sylvinus commented Jan 13, 2026

This is the first step in our standards-compliant JMAP support.

For now it implements just enough to be able to list recent threads and send emails.

We should:

  • Implement a bit more of the spec
  • Test it with actual JMAP clients (& check OIDC login)
  • Make this endpoint enabled with FEATURE_JMAP or something similar
  • Request feedback from the JMAP community

Summary by CodeRabbit

  • New Features

    • Added complete JMAP API support enabling efficient queries of mailboxes, emails, and threads
    • Implemented JMAP session management endpoint for full protocol compatibility
    • Added comprehensive error handling with standardized JMAP response formatting
  • Tests

    • Added extensive test coverage for JMAP methods, session management, and end-to-end mail workflows

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

This is the first step in our standards-compliant JMAP support. It
implements just enough to be able to list recent threads.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 13, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review
📝 Walkthrough

Walkthrough

This PR introduces a complete JMAP (RFC 8620) API implementation for the backend, including custom exception definitions, method handlers with back-reference resolution and property filtering, Django REST views for session and method-call processing, URL routing integration, and comprehensive test infrastructure.

Changes

Cohort / File(s) Summary
JMAP Module Foundation
src/backend/core/api/jmap/__init__.py
Module docstring defining JMAP API package
Error Handling
src/backend/core/api/jmap/errors.py
Seven custom exception classes (base JMAPError plus subclasses: UnknownCapabilityError, UnknownMethodError, InvalidArgumentsError, InvalidResultReferenceError, AccountNotFoundError, ForbiddenError, ServerFailError) with standard JMAP error response formatting
Method Registry & Handlers
src/backend/core/api/jmap/methods.py
MethodRegistry for handler dispatch; JMAPContext for execution state; BaseMethod abstract base with back-reference resolution and utility helpers; five public JMAP method implementations (MailboxQuery, MailboxGet, EmailQuery, EmailGet, ThreadGet) with filtering, pagination, property selection, and serialization
REST Endpoints
src/backend/core/api/jmap/views.py
JMAPSessionView (GET) exposing session capabilities, accounts, and URLs; JMAPAPIView (POST) processing method calls with argument resolution, error handling, and result accumulation
URL Configuration
src/backend/core/urls.py
Two new JMAP endpoints: /api/{version}/jmap/session and /api/{version}/jmap/
Test Infrastructure
src/backend/core/tests/api/jmap/__init__.py, src/backend/core/tests/api/jmap/conftest.py
Test package initialization; pytest fixtures (api_client, user, mailbox, mailbox_with_threads, jmap_client); JMAPTestClient for method invocation with reference handling and serialization; JMAPTestResponse and JMAPTestError utilities
Test Modules
src/backend/core/tests/api/jmap/test_jmap_session.py, src/backend/core/tests/api/jmap/test_jmap_methods.py, src/backend/core/tests/api/jmap/test_jmap_workflow.py
Session endpoint tests (capabilities, accounts, authentication); method-specific tests (MailboxQuery/Get, EmailQuery/Get, ThreadGet with filtering, sorting, properties); end-to-end workflow tests (multi-thread scenarios, date filtering, multiple mailboxes)
Dependencies
src/backend/pyproject.toml
Added jmapc==0.2.23 as optional dev dependency

Sequence Diagram

sequenceDiagram
    participant Client
    participant JMAPAPIView
    participant MethodRegistry
    participant BaseMethod
    participant Database

    Client->>JMAPAPIView: POST /jmap/ (methodCalls)
    JMAPAPIView->>JMAPAPIView: Validate request structure & capabilities
    
    loop For each methodCall
        JMAPAPIView->>JMAPAPIView: resolve_args (back-references)
        JMAPAPIView->>MethodRegistry: get_handler(method_name)
        MethodRegistry-->>JMAPAPIView: Handler class
        JMAPAPIView->>BaseMethod: execute(args, context)
        
        alt Account validation
            BaseMethod->>BaseMethod: _get_account_id(accountId)
        end
        
        alt Back-reference resolution
            BaseMethod->>BaseMethod: resolve_value(value, context)
            BaseMethod->>BaseMethod: _resolve_reference(ref)
            BaseMethod->>BaseMethod: _navigate_path(obj, path)
        end
        
        BaseMethod->>Database: Query (filter, sort, paginate)
        Database-->>BaseMethod: Results
        BaseMethod->>BaseMethod: Serialize results (mailbox/email)
        BaseMethod-->>JMAPAPIView: Method response
        JMAPAPIView->>JMAPAPIView: Accumulate in results[callId]
    end
    
    JMAPAPIView-->>Client: Response (methodResponses, sessionState)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

The changes introduce substantial new functionality with high logic density: multiple JMAP method implementations with overlapping but distinct business logic (filtering, sorting, pagination, property selection), intricate back-reference resolution with nested path traversal, and comprehensive error handling. The heterogeneous nature of individual method handlers—each with unique database queries and serialization—combined with the scope of new public APIs and supporting infrastructure (views, registry, test client) requires careful, multi-faceted review.

Suggested reviewers

  • sdemagny
  • Nastaliss

Poem

🐰 JMAP now hops through backend halls,
Methods dance and back-refs call,
Sessions bloom with capability's grace,
Threads and emails find their place!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding a JMAP endpoint. It directly corresponds to the primary deliverable of this PR, which introduces JMAP session and API views with supporting infrastructure.
Docstring Coverage ✅ Passed Docstring coverage is 82.09% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jmap

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.

"sessionState": datetime.now(dt_timezone.utc).isoformat(),
}

return Response(response)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI about 2 months ago

In general, the fix is to avoid returning raw exception messages in responses. Instead, log the full exception (including stack trace) on the server side and send back a generic error description to the client, following the JMAP spec’s serverFail semantics or a similar pattern.

Concretely, in JMAPAPIView.post, update the generic except Exception as e: handler to:

  • Stop including str(e) in the description field.
  • Replace it with a generic message such as "An internal server error occurred." or omit description altogether if that’s acceptable for your client.
  • Optionally, log the exception server-side (e.g., via Python’s logging module) so developers can still see the details while users cannot.

To implement this with minimal functional change:

  • Add an import for Python’s logging module at the top of src/backend/core/api/jmap/views.py.
  • Create a module-level logger (e.g., logger = logging.getLogger(__name__)).
  • In the except Exception as e: block, call logger.exception("Unhandled exception while processing JMAP method %s", method_name) to log the full stack trace.
  • Replace {"type": "serverFail", "description": str(e)} with {"type": "serverFail", "description": "An internal server error occurred."} (or similarly generic text).

All changes are confined to src/backend/core/api/jmap/views.py within the shown snippets: adding the logging import and logger definition near the top, and modifying the except Exception block in JMAPAPIView.post.

Suggested changeset 1
src/backend/core/api/jmap/views.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/backend/core/api/jmap/views.py b/src/backend/core/api/jmap/views.py
--- a/src/backend/core/api/jmap/views.py
+++ b/src/backend/core/api/jmap/views.py
@@ -3,6 +3,7 @@
 from datetime import datetime
 from datetime import timezone as dt_timezone
 from urllib.parse import urlencode, urlparse
+import logging
 
 from django.conf import settings
 from django.http import HttpResponse, HttpResponseRedirect
@@ -20,6 +21,8 @@
 )
 from .methods import JMAPContext, MethodRegistry, resolve_args
 
+logger = logging.getLogger(__name__)
+
 # JMAP capabilities we support
 JMAP_CAPABILITIES = {
     "urn:ietf:params:jmap:core": {
@@ -246,10 +249,16 @@
                 method_responses.append(e.to_response(call_id))
             except Exception as e:
                 # Catch any unexpected errors
+                logger.exception(
+                    "Unhandled exception while processing JMAP method %s", method_name
+                )
                 method_responses.append(
                     [
                         "error",
-                        {"type": "serverFail", "description": str(e)},
+                        {
+                            "type": "serverFail",
+                            "description": "An internal server error occurred.",
+                        },
                         call_id,
                     ]
                 )
EOF
@@ -3,6 +3,7 @@
from datetime import datetime
from datetime import timezone as dt_timezone
from urllib.parse import urlencode, urlparse
import logging

from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect
@@ -20,6 +21,8 @@
)
from .methods import JMAPContext, MethodRegistry, resolve_args

logger = logging.getLogger(__name__)

# JMAP capabilities we support
JMAP_CAPABILITIES = {
"urn:ietf:params:jmap:core": {
@@ -246,10 +249,16 @@
method_responses.append(e.to_response(call_id))
except Exception as e:
# Catch any unexpected errors
logger.exception(
"Unhandled exception while processing JMAP method %s", method_name
)
method_responses.append(
[
"error",
{"type": "serverFail", "description": str(e)},
{
"type": "serverFail",
"description": "An internal server error occurred.",
},
call_id,
]
)
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

@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: 4

🤖 Fix all issues with AI agents
In @src/backend/core/api/jmap/methods.py:
- Around line 179-195: The returned "total" is computed after pagination;
compute total_count = len(mailbox_ids) immediately after mailbox_ids =
list(mailboxes.values_list("id", flat=True)) and before applying the
position/limit slice, then use total_count in the returned dict's "total" field
while still slicing mailbox_ids for "ids"; adjust references in the method
(mailbox_ids, position, limit, and the returned dict) so "ids" contains the
paginated IDs but "total" reflects the full unpaged match count.
- Around line 105-119: Refactor the loop in _navigate_path to enumerate parts
(for idx, part in enumerate(parts)) so when part == "*" you compute
remaining_parts = parts[idx + 1:] (not parts.index(part)) to handle multiple
wildcards correctly; also restructure the control flow so there is no elif after
a return (e.g., use an if/return and then continue or fallthrough for other
cases) to satisfy the no-else-return lint rule, and build remaining_path = "/" +
"/".join(remaining_parts) and return [self._navigate_path(item, remaining_path)
for item in current] or current as before.

In @src/backend/core/api/jmap/views.py:
- Around line 181-189: The current except block in views.py appends str(e) into
method_responses which can leak internal details; change the handler in the
except Exception block (where method_responses.append([...]) is called) to stop
returning str(e) to the client—replace the response description with a generic
message like "Internal server error" or "serverFail" and record the real
exception internally by calling capture_exception() (from sentry_sdk) or logging
it via your logger before appending the generic error response; ensure call_id
and error type remain unchanged so clients can correlate errors without seeing
internal details.

In @src/backend/core/tests/api/jmap/test_jmap_methods.py:
- Around line 31-54: The test contains a local import "import uuid" inside
test_mailbox_query_filters_by_name; move that import to the module top-level
(add "import uuid" to the file imports) and remove the inline import from the
test function so test_mailbox_query_filters_by_name uses the top-level uuid
import instead of importing inside the function.
🧹 Nitpick comments (8)
src/backend/core/urls.py (1)

228-238: Consider adding trailing slash to session endpoint for consistency.

The /jmap/session endpoint lacks a trailing slash while /jmap/ has one. While this might be intentional per JMAP spec conventions, Django typically expects trailing slashes. Consider either:

  1. Adding trailing slash: jmap/session/
  2. Ensuring APPEND_SLASH setting handles this appropriately

This is a minor consistency observation - if the current pattern matches JMAP client expectations, it's fine to keep as-is.

src/backend/core/tests/api/jmap/test_jmap_session.py (1)

26-51: Address unused mailbox argument warnings.

The mailbox fixture is required to create the mailbox in the database (side effect), but Pylint warns about unused arguments. Use underscore prefix or explicit annotation to indicate intentional side-effect usage.

♻️ Suggested fix
-    def test_session_returns_account(self, api_client, user, mailbox):
+    def test_session_returns_account(self, api_client, user, mailbox):  # noqa: ARG002
         """Test that the session includes the user's account."""

Or rename to underscore prefix:

-    def test_session_returns_account(self, api_client, user, mailbox):
+    def test_session_returns_account(self, api_client, user, _mailbox):
         """Test that the session includes the user's account."""

Apply the same pattern to test_session_returns_primary_account at line 40.

src/backend/core/api/jmap/views.py (1)

61-68: Simplify first mailbox retrieval.

The for-loop pattern to get the first item is unnecessarily verbose.

♻️ Proposed simplification
         # Use the first mailbox email as the account name, or user email
-        primary_email = None
-        for mailbox in mailboxes[:1]:
-            primary_email = f"{mailbox.local_part}@{mailbox.domain.name}"
-            break
+        first_mailbox = mailboxes.first()
+        primary_email = (
+            f"{first_mailbox.local_part}@{first_mailbox.domain.name}"
+            if first_mailbox
+            else None
+        )
src/backend/core/tests/api/jmap/test_jmap_workflow.py (1)

144-150: Move import to top of file.

The Message import inside the test function triggers linting warnings. Move it to the top-level imports for consistency and linter compliance.

♻️ Proposed fix

At the top of the file (around line 21):

 from core import enums, factories
+from core.models import Message

Then remove line 145:

-        from core.models import Message
-
         Message.objects.filter(id=recent_msg.id).update(
src/backend/core/tests/api/jmap/conftest.py (1)

317-321: Minor: Remove unnecessary pass statement.

Exception classes with docstrings don't need an explicit pass.

♻️ Proposed fix
 class JMAPTestError(Exception):
     """Exception raised when a JMAP method call fails."""
-
-    pass
src/backend/core/api/jmap/methods.py (3)

242-253: N+1 query problem: 4 COUNT queries per mailbox.

Each call to _serialize_mailbox executes 4 separate database queries for counts. For M mailboxes, this results in 4M additional queries, which will degrade performance significantly with many mailboxes.

Consider annotating the queryset with counts using Django's Count and Case/When:

from django.db.models import Count, Case, When, IntegerField

mailboxes = models.Mailbox.objects.filter(
    accesses__user=self.context.user
).annotate(
    total_emails=Count('accesses__thread__messages', distinct=True),
    unread_emails=Count(
        Case(When(accesses__thread__messages__is_unread=True, then=1)),
        output_field=IntegerField()
    ),
    # ... similar for threads
)

This would reduce the query count from O(4M) to O(1).


406-411: N+1 query: ThreadAccess query inside serialization loop.

Each email serialization executes a separate query for mailbox_ids. With N emails, this adds N extra queries.

Consider prefetching or batching

You could prefetch thread__accesses in the main query:

         messages = (
             models.Message.objects.filter(id__in=ids)
             .filter(thread__accesses__mailbox__accesses__user=self.context.user)
-            .select_related("sender", "thread", "blob")
-            .prefetch_related("recipients__contact")
+            .select_related("sender", "thread", "blob")
+            .prefetch_related("recipients__contact", "thread__accesses")
         )

Then in _serialize_email:

mailbox_ids = [access.mailbox_id for access in message.thread.accesses.all()]

481-491: Potential prefetch inefficiency: .order_by() may bypass prefetch cache.

Calling .order_by("created_at") on thread.messages inside the loop may issue a new database query per thread, bypassing the prefetch_related("messages") optimization.

Use Prefetch with ordering or sort in Python

Option 1 - Use Prefetch with ordering:

from django.db.models import Prefetch

threads = models.Thread.objects.filter(
    id__in=ids, accesses__mailbox__accesses__user=self.context.user
).prefetch_related(
    Prefetch("messages", queryset=models.Message.objects.order_by("created_at"))
)

Then remove the .order_by():

"emailIds": [str(m.id) for m in thread.messages.all()],

Option 2 - Sort in Python:

"emailIds": [str(m.id) for m in sorted(thread.messages.all(), key=lambda m: m.created_at)],
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bef485a and 3c941dc.

⛔ Files ignored due to path filters (1)
  • src/backend/poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • src/backend/core/api/jmap/__init__.py
  • src/backend/core/api/jmap/errors.py
  • src/backend/core/api/jmap/methods.py
  • src/backend/core/api/jmap/views.py
  • src/backend/core/tests/api/jmap/__init__.py
  • src/backend/core/tests/api/jmap/conftest.py
  • src/backend/core/tests/api/jmap/test_jmap_methods.py
  • src/backend/core/tests/api/jmap/test_jmap_session.py
  • src/backend/core/tests/api/jmap/test_jmap_workflow.py
  • src/backend/core/urls.py
  • src/backend/pyproject.toml
🧰 Additional context used
📓 Path-based instructions (6)
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/jmap/__init__.py
  • src/backend/core/tests/api/jmap/conftest.py
  • src/backend/core/tests/api/jmap/__init__.py
  • src/backend/core/urls.py
  • src/backend/core/api/jmap/errors.py
  • src/backend/core/tests/api/jmap/test_jmap_workflow.py
  • src/backend/core/tests/api/jmap/test_jmap_session.py
  • src/backend/core/tests/api/jmap/test_jmap_methods.py
  • src/backend/core/api/jmap/views.py
  • src/backend/core/api/jmap/methods.py
src/backend/**/{tests.py,tests/**/*.py}

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

src/backend/**/{tests.py,tests/**/*.py}: Use Django’s testing tools (pytest-django) to ensure code quality and reliability
Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases

Files:

  • src/backend/core/tests/api/jmap/conftest.py
  • src/backend/core/tests/api/jmap/__init__.py
  • src/backend/core/tests/api/jmap/test_jmap_workflow.py
  • src/backend/core/tests/api/jmap/test_jmap_session.py
  • src/backend/core/tests/api/jmap/test_jmap_methods.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
src/backend/**/views.py

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

src/backend/**/views.py: Use Django REST Framework viewsets for API endpoints
Implement error handling at the view level using Django’s built-in mechanisms
Optimize related object fetching with select_related and prefetch_related

Files:

  • src/backend/core/api/jmap/views.py
src/backend/**/{models.py,forms.py,views.py}

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

Keep business logic in models and forms; keep views thin and focused on request handling

Files:

  • src/backend/core/api/jmap/views.py
src/backend/**/{views.py,tasks.py}

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

Use asynchronous views and Celery tasks for I/O-bound or long-running operations

Files:

  • src/backend/core/api/jmap/views.py
🧠 Learnings (2)
📚 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/urls.py
  • src/backend/core/api/jmap/views.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/tests/api/jmap/conftest.py (3)
src/backend/core/factories.py (7)
  • UserFactory (20-31)
  • MailboxFactory (60-96)
  • users_read (71-81)
  • ThreadFactory (125-132)
  • ThreadAccessFactory (135-145)
  • ContactFactory (148-157)
  • MessageFactory (160-186)
src/backend/core/models.py (1)
  • update_stats (798-927)
src/backend/core/api/jmap/views.py (2)
  • get (52-100)
  • post (113-196)
src/backend/core/urls.py (1)
src/backend/core/api/jmap/views.py (2)
  • JMAPAPIView (103-196)
  • JMAPSessionView (43-100)
src/backend/core/tests/api/jmap/test_jmap_workflow.py (4)
src/backend/core/api/jmap/methods.py (4)
  • EmailGet (369-464)
  • EmailQuery (277-365)
  • MailboxGet (199-270)
  • ThreadGet (471-501)
src/backend/core/tests/api/jmap/conftest.py (5)
  • jmap_client (324-326)
  • user (22-24)
  • mailbox (28-30)
  • request (88-159)
  • ids (299-301)
src/backend/core/factories.py (7)
  • MailboxFactory (60-96)
  • users_read (71-81)
  • ThreadFactory (125-132)
  • ThreadAccessFactory (135-145)
  • ContactFactory (148-157)
  • MessageFactory (160-186)
  • MailDomainFactory (51-57)
src/backend/core/models.py (1)
  • update_stats (798-927)
src/backend/core/tests/api/jmap/test_jmap_session.py (3)
src/backend/core/tests/api/jmap/conftest.py (3)
  • api_client (16-18)
  • user (22-24)
  • mailbox (28-30)
src/backend/core/api/jmap/views.py (1)
  • get (52-100)
src/backend/core/factories.py (2)
  • MailboxFactory (60-96)
  • users_read (71-81)
src/backend/core/tests/api/jmap/test_jmap_methods.py (4)
src/backend/core/api/jmap/methods.py (5)
  • EmailGet (369-464)
  • EmailQuery (277-365)
  • MailboxGet (199-270)
  • MailboxQuery (160-195)
  • ThreadGet (471-501)
src/backend/core/tests/api/jmap/conftest.py (6)
  • jmap_client (324-326)
  • mailbox (28-30)
  • request (88-159)
  • ids (299-301)
  • user (22-24)
  • not_found (309-311)
src/backend/core/factories.py (7)
  • MailDomainFactory (51-57)
  • MailboxFactory (60-96)
  • users_read (71-81)
  • ThreadFactory (125-132)
  • ThreadAccessFactory (135-145)
  • ContactFactory (148-157)
  • MessageFactory (160-186)
src/backend/core/tests/api/conftest.py (1)
  • message (77-81)
src/backend/core/api/jmap/methods.py (3)
src/backend/core/api/jmap/errors.py (3)
  • InvalidArgumentsError (34-37)
  • InvalidResultReferenceError (40-43)
  • UnknownMethodError (28-31)
src/backend/core/enums.py (1)
  • MessageRecipientTypeChoices (31-36)
src/backend/core/models.py (1)
  • size (1634-1636)
🪛 GitHub Actions: Lint and tests
src/backend/core/tests/api/jmap/conftest.py

[warning] 28-28: Pylint: W0621 Redefining name 'user' from outer scope (line 22).


[warning] 34-34: Pylint: W0621 Redefining name 'user' from outer scope (line 22).


[warning] 36-36: Pylint: W0621 Redefining name 'mailbox' from outer scope (line 28).

src/backend/core/tests/api/jmap/test_jmap_workflow.py

[warning] 145-145: Pylint: C0415 Import outside toplevel (core.models.Message).

src/backend/core/tests/api/jmap/test_jmap_session.py

[warning] 26-26: Pylint: W0613 Unused argument 'mailbox'.


[warning] 40-40: Pylint: W0613 Unused argument 'mailbox'.

src/backend/core/tests/api/jmap/test_jmap_methods.py

[warning] 33-33: Pylint: C0415 Import outside toplevel (uuid).

src/backend/core/api/jmap/views.py

[warning] 181-181: Pylint: W0718 Catching too general exception Exception (broad-exception-caught).

src/backend/core/api/jmap/methods.py

[error] 106-106: Pylint: R1705 Unnecessary "elif" after "return". Remove the leading "el" from "elif" (no-else-return).

🪛 GitHub Check: CodeQL
src/backend/core/api/jmap/views.py

[warning] 196-196: Information exposure through an exception
Stack trace information flows to this location and may be exposed to an external user.

🪛 Ruff (0.14.11)
src/backend/core/tests/api/jmap/test_jmap_workflow.py

145-145: import should be at the top-level of a file

(PLC0415)

src/backend/core/tests/api/jmap/test_jmap_methods.py

33-33: import should be at the top-level of a file

(PLC0415)

🔇 Additional comments (22)
src/backend/core/tests/api/jmap/__init__.py (1)

1-1: LGTM - standard test package initialization.

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

8-8: LGTM - JMAP view imports follow existing conventions.

src/backend/core/api/jmap/__init__.py (1)

1-1: LGTM - standard package initialization.

src/backend/core/tests/api/jmap/test_jmap_methods.py (6)

1-19: LGTM - well-organized imports and test setup.

Good use of pytestmark to apply django_db marker to all tests in the module.


56-67: Good coverage for access control.

Testing that inaccessible mailboxes are properly excluded is important for security validation.


69-108: LGTM - comprehensive Mailbox/get coverage.

Good coverage including the back-reference test (Line 92) which validates an important JMAP protocol feature.


110-194: LGTM - thorough Email/query testing.

Good coverage of JMAP-specific features including collapseThreads behavior and sort ordering. The verification loop at Lines 163-169 properly validates that thread collapsing works correctly.


196-266: LGTM - solid Email/get test coverage.

Good testing of partial property selection (Lines 234-242) and keyword flag serialization (Lines 244-265).


268-308: LGTM - Thread/get tests validate ordering correctly.

Good verification that emailIds are returned in chronological order (oldest to newest) at Line 299.

src/backend/pyproject.toml (1)

82-82: LGTM - dev dependency for JMAP testing.

The jmapc==0.2.23 library is appropriately added as a dev dependency (correctly alphabetized), supporting the JMAP endpoint testing implemented in the codebase.

src/backend/core/api/jmap/errors.py (1)

1-61: LGTM!

Clean implementation of JMAP error types per RFC 8620. The class hierarchy is well-structured with appropriate error_type values and the to_response() method correctly formats the error response tuple.

src/backend/core/tests/api/jmap/test_jmap_session.py (1)

62-81: LGTM!

The authentication test and mailbox email name test are well-structured. The inline mailbox creation in test_session_uses_mailbox_email_as_name with explicit local_part and domain__name is appropriate for testing specific email formatting.

src/backend/core/api/jmap/views.py (1)

21-40: LGTM!

The JMAP capabilities constant and session response structure follow RFC 8620 correctly. The session view properly handles user mailboxes, builds appropriate URLs, and includes all required session properties.

Also applies to: 43-100

src/backend/core/tests/api/jmap/test_jmap_workflow.py (2)

26-121: LGTM!

The test_recent_threads_full_workflow test is well-structured, following the three-step JMAP workflow pattern. Good use of unique domain names via UUID to prevent test isolation issues, and proper setup of threads with messages.


186-254: LGTM!

The empty mailbox and multiple mailboxes tests provide good edge case coverage. The assertions are focused and verify the expected behavior correctly.

src/backend/core/tests/api/jmap/conftest.py (3)

27-31: Pylint warnings are false positives for pytest fixture pattern.

The warnings about redefining user and mailbox from outer scope are expected in pytest. Fixtures that depend on other fixtures naturally reuse parameter names. No changes needed.

Also applies to: 33-61


64-159: LGTM!

The JMAPTestClient class is well-designed to bridge DRF's APIClient with jmapc method objects. The two-pass approach for handling call IDs and Ref resolution is correct. Good documentation and error handling with the raise_errors parameter.


213-288: LGTM!

The _process_refs method correctly handles multiple Ref reference types (previous method via -1, integer index, or method name string) and properly converts them to JMAP back-reference format. The recursive handling of nested dicts is appropriate.

src/backend/core/api/jmap/methods.py (4)

19-46: LGTM!

The registry pattern is well-implemented with clean decorator syntax and proper error handling for unknown methods.


48-54: LGTM!

Simple and effective context container for passing user and results between method calls.


140-153: LGTM!

The function correctly resolves #-prefixed back-reference keys per JMAP spec.


276-344: LGTM!

The query logic is well-structured: filters are applied correctly, total is computed before pagination, and the collapseThreads implementation using subqueries is efficient.

Comment on lines +105 to +119
for part in parts:
if part == "*":
# Wildcard: extract from all items in list
if not isinstance(current, list):
raise InvalidResultReferenceError(
f"Cannot use '*' on non-list: {type(current)}"
)
# Return the remaining path applied to each item
remaining_parts = parts[parts.index(part) + 1 :]
if remaining_parts:
remaining_path = "/" + "/".join(remaining_parts)
return [
self._navigate_path(item, remaining_path) for item in current
]
return current
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix elif after return to satisfy linter, and address potential bug with multiple wildcards.

Two issues in this block:

  1. Linter violation (Line 106): The elif after the return on line 119 triggers Pylint's no-else-return rule.

  2. Potential bug (Line 113): Using parts.index(part) when part == "*" will always find the first occurrence of "*" in parts, not the current position. If a path contains multiple wildcards (e.g., /list/*/nested/*/id), this will produce incorrect remaining paths.

Proposed fix using enumeration to track position
-        for part in parts:
+        for i, part in enumerate(parts):
             if part == "*":
                 # Wildcard: extract from all items in list
                 if not isinstance(current, list):
                     raise InvalidResultReferenceError(
                         f"Cannot use '*' on non-list: {type(current)}"
                     )
                 # Return the remaining path applied to each item
-                remaining_parts = parts[parts.index(part) + 1 :]
+                remaining_parts = parts[i + 1 :]
                 if remaining_parts:
                     remaining_path = "/" + "/".join(remaining_parts)
                     return [
                         self._navigate_path(item, remaining_path) for item in current
                     ]
                 return current
-            elif isinstance(current, dict):
+            if isinstance(current, dict):
🧰 Tools
🪛 GitHub Actions: Lint and tests

[error] 106-106: Pylint: R1705 Unnecessary "elif" after "return". Remove the leading "el" from "elif" (no-else-return).

🤖 Prompt for AI Agents
In @src/backend/core/api/jmap/methods.py around lines 105 - 119, Refactor the
loop in _navigate_path to enumerate parts (for idx, part in enumerate(parts)) so
when part == "*" you compute remaining_parts = parts[idx + 1:] (not
parts.index(part)) to handle multiple wildcards correctly; also restructure the
control flow so there is no elif after a return (e.g., use an if/return and then
continue or fallthrough for other cases) to satisfy the no-else-return lint
rule, and build remaining_path = "/" + "/".join(remaining_parts) and return
[self._navigate_path(item, remaining_path) for item in current] or current as
before.

Comment on lines +179 to +195
# Get IDs
mailbox_ids = list(mailboxes.values_list("id", flat=True))

# Apply pagination
if limit is not None:
mailbox_ids = mailbox_ids[position : position + limit]
else:
mailbox_ids = mailbox_ids[position:]

return {
"accountId": account_id,
"queryState": self._get_state(),
"canCalculateChanges": False,
"position": position,
"ids": [str(mid) for mid in mailbox_ids],
"total": len(mailbox_ids),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

total returns paginated count instead of total matching count.

Per JMAP spec, total should represent the total number of results in the query before pagination is applied. Currently, total is computed from len(mailbox_ids) after slicing.

Proposed fix
         # Get IDs
-        mailbox_ids = list(mailboxes.values_list("id", flat=True))
+        all_mailbox_ids = list(mailboxes.values_list("id", flat=True))
+        total_count = len(all_mailbox_ids)

         # Apply pagination
         if limit is not None:
-            mailbox_ids = mailbox_ids[position : position + limit]
+            mailbox_ids = all_mailbox_ids[position : position + limit]
         else:
-            mailbox_ids = mailbox_ids[position:]
+            mailbox_ids = all_mailbox_ids[position:]

         return {
             "accountId": account_id,
             "queryState": self._get_state(),
             "canCalculateChanges": False,
             "position": position,
             "ids": [str(mid) for mid in mailbox_ids],
-            "total": len(mailbox_ids),
+            "total": total_count,
         }
🤖 Prompt for AI Agents
In @src/backend/core/api/jmap/methods.py around lines 179 - 195, The returned
"total" is computed after pagination; compute total_count = len(mailbox_ids)
immediately after mailbox_ids = list(mailboxes.values_list("id", flat=True)) and
before applying the position/limit slice, then use total_count in the returned
dict's "total" field while still slicing mailbox_ids for "ids"; adjust
references in the method (mailbox_ids, position, limit, and the returned dict)
so "ids" contains the paginated IDs but "total" reflects the full unpaged match
count.

Comment on lines +181 to +189
except Exception as e:
# Catch any unexpected errors
method_responses.append(
[
"error",
{"type": "serverFail", "description": str(e)},
call_id,
]
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Security: Avoid exposing internal exception details to clients.

The generic exception handler exposes str(e) directly in the response, which can leak internal implementation details, file paths, or stack traces. This is flagged by CodeQL as an information disclosure risk.

🔒 Proposed fix
             except JMAPError as e:
                 method_responses.append(e.to_response(call_id))
-            except Exception as e:
+            except Exception:
                 # Catch any unexpected errors
+                # Log the actual exception for debugging, but return generic message
+                import logging
+                logger = logging.getLogger(__name__)
+                logger.exception("Unexpected error processing JMAP method %s", method_name)
                 method_responses.append(
                     [
                         "error",
-                        {"type": "serverFail", "description": str(e)},
+                        {"type": "serverFail", "description": "An unexpected error occurred"},
                         call_id,
                     ]
                 )

Consider also reporting to Sentry per coding guidelines:

from sentry_sdk import capture_exception
# ...
except Exception:
    capture_exception()
    # ...
📝 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
except Exception as e:
# Catch any unexpected errors
method_responses.append(
[
"error",
{"type": "serverFail", "description": str(e)},
call_id,
]
)
except Exception:
# Catch any unexpected errors
# Log the actual exception for debugging, but return generic message
import logging
logger = logging.getLogger(__name__)
logger.exception("Unexpected error processing JMAP method %s", method_name)
method_responses.append(
[
"error",
{"type": "serverFail", "description": "An unexpected error occurred"},
call_id,
]
)
🧰 Tools
🪛 GitHub Actions: Lint and tests

[warning] 181-181: Pylint: W0718 Catching too general exception Exception (broad-exception-caught).

🤖 Prompt for AI Agents
In @src/backend/core/api/jmap/views.py around lines 181 - 189, The current
except block in views.py appends str(e) into method_responses which can leak
internal details; change the handler in the except Exception block (where
method_responses.append([...]) is called) to stop returning str(e) to the
client—replace the response description with a generic message like "Internal
server error" or "serverFail" and record the real exception internally by
calling capture_exception() (from sentry_sdk) or logging it via your logger
before appending the generic error response; ensure call_id and error type
remain unchanged so clients can correlate errors without seeing internal
details.

Comment on lines +31 to +54
def test_mailbox_query_filters_by_name(self, jmap_client, user):
"""Test that Mailbox/query can filter by name."""
import uuid

# Create mailboxes with specific names using unique domain
domain_name = f"filter-{uuid.uuid4().hex[:8]}.com"
domain = factories.MailDomainFactory(name=domain_name)
mailbox1 = factories.MailboxFactory(
local_part="inbox",
domain=domain,
users_read=[user],
)
factories.MailboxFactory(
local_part="other",
domain=domain,
users_read=[user],
)

result = jmap_client.request(
MailboxQuery(filter={"name": f"inbox@{domain_name}"})
)

assert str(mailbox1.id) in result.ids
assert len(result.ids) == 1
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Move uuid import to top-level.

The pipeline flagged this: import uuid should be at the top of the file, not inside the test function.

Proposed fix

Add to imports at the top of the file (around line 7):

import uuid

Then remove line 33:

     def test_mailbox_query_filters_by_name(self, jmap_client, user):
         """Test that Mailbox/query can filter by name."""
-        import uuid
-
         # Create mailboxes with specific names using unique domain
🧰 Tools
🪛 GitHub Actions: Lint and tests

[warning] 33-33: Pylint: C0415 Import outside toplevel (uuid).

🪛 Ruff (0.14.11)

33-33: import should be at the top-level of a file

(PLC0415)

🤖 Prompt for AI Agents
In @src/backend/core/tests/api/jmap/test_jmap_methods.py around lines 31 - 54,
The test contains a local import "import uuid" inside
test_mailbox_query_filters_by_name; move that import to the module top-level
(add "import uuid" to the file imports) and remove the inline import from the
test function so test_mailbox_query_filters_by_name uses the top-level uuid
import instead of importing inside the function.

@chibenwa
Copy link
Copy Markdown

OIDC login

Please follow what's done by stallwart (and not followed by Twake Mail Flutter currently): expose (and redirect) .well-known/openid-configuration.

Twake mail uses a more complex mechanism atm (OpenID discovery and webfinger) but retrospectively we would be better served by a simpler setup.

Minimal feature list

/changes !

@sylvinus sylvinus marked this pull request as draft January 13, 2026 23:59
)

# Redirect back to the demo with the token in the URL fragment
return HttpResponseRedirect(f"{stored_redirect}#access_token={access_token}")

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI about 2 months ago

In general, to fix untrusted URL redirection you must not redirect directly to arbitrary user-provided URLs. Instead, either (a) maintain a whitelist of allowed redirect targets (domains or full URLs), or (b) only allow relative paths on the same host, using Django’s url_has_allowed_host_and_scheme helper to enforce this. This code is already using Django, so the best low-impact fix is to validate redirect_uri with url_has_allowed_host_and_scheme, restricting it to allowed hosts and safe schemes before putting it into the session and later redirecting to it.

Concretely for JMAPOIDCLoginView.get in src/backend/core/api/jmap/views.py:

  • Import url_has_allowed_host_and_scheme from django.utils.http.
  • Replace the custom urlparse-based validation (lines 124–128) with a call to url_has_allowed_host_and_scheme. Use allowed_hosts={request.get_host()} (or settings.ALLOWED_HOSTS if you want more flexibility) and restrict schemes to ["http", "https"].
  • Only store redirect_uri in the session if it passes that check; otherwise, return a 400 error as currently done.
  • Optionally, to make CodeQL’s reasoning clearer, keep using stored_redirect but add a comment that it has already been validated using Django’s helper.

This preserves existing behaviour for legitimate same-origin redirects while blocking redirects to arbitrary external domains, and requires only a small code change.

Suggested changeset 1
src/backend/core/api/jmap/views.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/backend/core/api/jmap/views.py b/src/backend/core/api/jmap/views.py
--- a/src/backend/core/api/jmap/views.py
+++ b/src/backend/core/api/jmap/views.py
@@ -6,6 +6,7 @@
 
 from django.conf import settings
 from django.http import HttpResponse, HttpResponseRedirect
+from django.utils.http import url_has_allowed_host_and_scheme
 from django.views import View
 
 from rest_framework.parsers import JSONParser
@@ -122,9 +123,13 @@
 
         # Store redirect_uri in session on first visit
         if redirect_uri:
-            # Validate redirect_uri to prevent open redirects
-            parsed = urlparse(redirect_uri)
-            if not parsed.scheme or not parsed.netloc:
+            # Validate redirect_uri to prevent open redirects.
+            # Only allow redirects to the current host (or its aliases) over http/https.
+            if not url_has_allowed_host_and_scheme(
+                url=redirect_uri,
+                allowed_hosts={request.get_host()},
+                require_https=request.is_secure(),
+            ):
                 return HttpResponse("Invalid redirect_uri", status=400)
             request.session["jmap_oidc_redirect_uri"] = redirect_uri
 
EOF
@@ -6,6 +6,7 @@

from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View

from rest_framework.parsers import JSONParser
@@ -122,9 +123,13 @@

# Store redirect_uri in session on first visit
if redirect_uri:
# Validate redirect_uri to prevent open redirects
parsed = urlparse(redirect_uri)
if not parsed.scheme or not parsed.netloc:
# Validate redirect_uri to prevent open redirects.
# Only allow redirects to the current host (or its aliases) over http/https.
if not url_has_allowed_host_and_scheme(
url=redirect_uri,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return HttpResponse("Invalid redirect_uri", status=400)
request.session["jmap_oidc_redirect_uri"] = redirect_uri

Copilot is powered by AI and may make mistakes. Always verify output.
@sylvinus sylvinus mentioned this pull request Feb 7, 2026
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