====
JMAP
====

**The JMAP support in v3.0 is experimental, the API may change in v3.1 of the library**

The caldav library includes a JMAP client for servers that speak
:rfc:`8620` (JMAP Core) and
the JMAP Calendars protocol (``urn:ietf:params:jmap:calendars``), which uses
:rfc:`8984` (JSCalendar) as its data format.
It covers calendar listing, event CRUD, incremental sync, and task CRUD — the same
operations as the CalDAV client — so the choice of protocol comes down to what the
server supports.

.. note::

   The JMAP client targets servers implementing
   ``urn:ietf:params:jmap:calendars``.  Cyrus IMAP is the primary tested server.
   Task support (``urn:ietf:params:jmap:tasks``) requires a separate server
   capability; Cyrus does not implement it yet.

Quick Start
===========

.. code-block:: python

    from caldav.jmap import get_jmap_client

    client = get_jmap_client(
        url="https://jmap.example.com/.well-known/jmap",
        username="alice",
        password="secret",
    )
    calendars = client.get_calendars()
    for cal in calendars:
        print(cal.name)

:func:`~caldav.jmap.get_jmap_client` reads configuration from the same sources
as :func:`caldav.get_davclient`: explicit keyword arguments, then the
``CALDAV_URL`` / ``CALDAV_USERNAME`` / ``CALDAV_PASSWORD`` environment variables,
then a config file.  If none of those are set it returns ``None``.

With environment variables or a config file in place, no arguments are needed:

.. code-block:: python

    client = get_jmap_client()   # reads env vars or config file

Authentication
==============

HTTP Basic auth is used when a ``username`` is supplied alongside a ``password``.
Bearer token auth is used when only a ``password`` (token) is given and no username.
You can also pass any ``requests``-compatible auth object directly via the ``auth``
parameter (niquests is API-compatible with requests).

.. code-block:: python

    # Basic auth
    client = get_jmap_client(
        url="https://jmap.example.com/.well-known/jmap",
        username="alice",
        password="secret",
    )

    # Bearer token (password argument holds the token; no username supplied)
    client = get_jmap_client(
        url="https://jmap.example.com/.well-known/jmap",
        password="my-bearer-token",
    )

    # Pre-built auth object
    try:
        from niquests.auth import HTTPBasicAuth
    except ImportError:
        from requests.auth import HTTPBasicAuth
    client = get_jmap_client(
        url="https://jmap.example.com/.well-known/jmap",
        auth=HTTPBasicAuth("alice", "secret"),
    )

Unlike CalDAV, JMAP does not use a 401-challenge-retry dance — credentials are sent
on every request, and a 401 or 403 is a hard :class:`~caldav.jmap.error.JMAPAuthError`.

Context manager usage is supported but not required — no persistent TCP connection is
held between calls (the JMAP Session object is cached after the first request, but
that is just a JSON document, not a socket):

.. code-block:: python

    with get_jmap_client(...) as client:
        calendars = client.get_calendars()

Listing Calendars
=================

.. code-block:: python

    calendars = client.get_calendars()
    for cal in calendars:
        print(cal.id, cal.name, cal.color)

Each item is a :class:`~caldav.jmap.objects.calendar.JMAPCalendar` dataclass.
The fields are ``id``, ``name``, ``description``, ``color`` (CSS string or ``None``),
``is_subscribed``, ``my_rights`` (dict), ``sort_order``, and ``is_visible``.

Working with Events
===================

Events are passed as iCalendar strings — the same format used by the CalDAV client
— so existing iCalendar-producing code works unchanged.

The calendar-scoped API mirrors :class:`caldav.collection.Calendar`:

.. code-block:: python

    cal = calendars[0]

    ical = (
        "BEGIN:VCALENDAR\r\n"
        "VERSION:2.0\r\n"
        "PRODID:-//example//EN\r\n"
        "BEGIN:VEVENT\r\n"
        "UID:meeting-2026-01-15@example.com\r\n"
        "SUMMARY:Team meeting\r\n"
        "DTSTART:20260115T100000Z\r\n"
        "DTEND:20260115T110000Z\r\n"
        "END:VEVENT\r\n"
        "END:VCALENDAR\r\n"
    )

    # Add an event to this calendar — returns the server-assigned JMAP event ID
    event_id = cal.add_event(ical)

    # Look up an event by its iCalendar UID — returns a VCALENDAR string
    ical_str = cal.get_object_by_uid("meeting-2026-01-15@example.com")

If you already have a JMAP event ID (from :meth:`~caldav.jmap.client.JMAPClient.get_sync_token`
results, for example), you can also use the lower-level client methods directly:

.. code-block:: python

    # Fetch by JMAP event ID — returns a VCALENDAR string
    ical_str = client.get_event(event_id)

    # Update — pass a complete VCALENDAR string with the changes applied
    updated = ical_str.replace("Team meeting", "Team standup")
    client.update_event(event_id, updated)

    # Delete
    client.delete_event(event_id)

Searching Events
================

Use :meth:`~caldav.jmap.objects.calendar.JMAPCalendar.search` on a calendar object,
mirroring the CalDAV :meth:`caldav.collection.Calendar.search` interface:

.. code-block:: python

    cal = calendars[0]

    # All events in this calendar
    results = cal.search(event=True)

    # Time-range filter: events that overlap [start, end)
    #   start — only events ending after this datetime
    #   end   — only events starting before this datetime
    results = cal.search(
        event=True,
        start="2026-01-01T00:00:00",
        end="2026-02-01T00:00:00",
    )

    # Free-text search across title, description, locations, and participants
    results = cal.search(text="standup")

    for ical_str in results:
        print(ical_str)

All parameters are optional; omitting all returns every event in the calendar.
Results are returned as a list of VCALENDAR strings.  The search uses a single batched
JMAP request (``CalendarEvent/query`` + result reference into ``CalendarEvent/get``),
so only one HTTP round-trip is made regardless of how many events match.

Incremental Sync
================

JMAP's state-based sync lets you fetch only what changed since the last call, without
scanning the full calendar:

.. code-block:: python

    # Record the current state
    token = client.get_sync_token()

    # ... time passes, events are created/modified/deleted ...

    # Fetch only the delta
    added, modified, deleted = client.get_objects_by_sync_token(token)

    for ical_str in added:
        print("New:", ical_str)
    for ical_str in modified:
        print("Updated:", ical_str)
    for event_id in deleted:
        print("Deleted ID:", event_id)

``added`` and ``modified`` are lists of VCALENDAR strings.  ``deleted`` is a list
of event IDs — the objects no longer exist on the server, so their data cannot be
fetched.

:meth:`~caldav.jmap.client.JMAPClient.get_objects_by_sync_token` raises
:class:`~caldav.jmap.error.JMAPMethodError` (``error_type="serverPartialFail"``) if
the server truncated the change list (``hasMoreChanges: true``).  If this happens,
call :meth:`~caldav.jmap.client.JMAPClient.get_sync_token` to establish a fresh
baseline and re-sync from scratch.

A typical pattern is to persist the token between runs:

.. code-block:: python

    import json
    import pathlib

    TOKEN_FILE = pathlib.Path("sync_token.json")

    def load_token():
        if TOKEN_FILE.exists():
            return json.loads(TOKEN_FILE.read_text())["token"]
        return None

    def save_token(token):
        TOKEN_FILE.write_text(json.dumps({"token": token}))

    token = load_token()
    if token is None:
        token = client.get_sync_token()
        save_token(token)
    else:
        added, modified, deleted = client.get_objects_by_sync_token(token)
        # process changes ...
        token = client.get_sync_token()
        save_token(token)

Tasks
=====

Task support requires a server implementing ``urn:ietf:params:jmap:tasks``
(the JMAP Tasks specification).  If the server does not support this capability,
:meth:`~caldav.jmap.client.JMAPClient.get_task_lists` will raise
:class:`~caldav.jmap.error.JMAPMethodError`.

.. code-block:: python

    # List task lists
    task_lists = client.get_task_lists()
    for tl in task_lists:
        print(tl.id, tl.name)

    task_list_id = task_lists[0].id

    # Create a task — title is required; everything else is optional
    task_id = client.create_task(
        task_list_id,
        title="Review pull request",
        due="2026-02-15T17:00:00",
        time_zone="Europe/Oslo",
    )

    # Fetch — returns a JMAPTask dataclass
    task = client.get_task(task_id)
    print(task.title)          # str
    print(task.progress)       # "needs-action" (default)
    print(task.percent_complete)  # 0 (default)

    # Update — pass a partial patch dict using JMAP wire property names
    client.update_task(task_id, {"progress": "completed", "percentComplete": 100})

    # Delete
    client.delete_task(task_id)

Optional kwargs for :meth:`~caldav.jmap.client.JMAPClient.create_task`:
``description``, ``start``, ``due``, ``time_zone``, ``estimated_duration``,
``percent_complete``, ``progress``, ``priority``.

Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task` is a
:class:`~caldav.jmap.objects.task.JMAPTask` with fields ``id``, ``uid``,
``task_list_id``, ``title``, ``description``, ``start``, ``due``, ``time_zone``,
``estimated_duration``, ``percent_complete``, ``progress``, ``progress_updated``,
``priority``, ``is_draft``, ``keywords``, ``recurrence_rules``,
``recurrence_overrides``, ``alerts``, ``participants``, ``color``, ``privacy``.

Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task_lists` is a
:class:`~caldav.jmap.objects.task.JMAPTaskList` with fields ``id``, ``name``,
``description``, ``color``, ``is_subscribed``, ``my_rights``, ``sort_order``,
``time_zone``, ``role`` (``"inbox"``, ``"trash"``, or ``None``).

Async API
=========

:class:`~caldav.jmap.async_client.AsyncJMAPClient` mirrors every method of
:class:`~caldav.jmap.client.JMAPClient` as a coroutine.  Use it as an
``async with`` context manager (sync ``with`` is not supported):

.. code-block:: python

    import asyncio
    from caldav.jmap import get_async_jmap_client

    async def main():
        async with get_async_jmap_client(
            url="https://jmap.example.com/.well-known/jmap",
            username="alice",
            password="secret",
        ) as client:
            calendars = await client.get_calendars()
            for cal in calendars:
                print(cal.name)

            # Calendar-scoped methods return coroutines when the calendar
            # was obtained from an async client
            cal = calendars[0]
            results = await cal.search(event=True)
            ical_str = await cal.get_object_by_uid("some-uid@example.com")
            event_id = await cal.add_event(ical)

    asyncio.run(main())

All methods — event CRUD, search, sync, and task operations — are available as
coroutines with identical signatures.  The async client uses ``niquests.AsyncSession``
internally; ``niquests`` is a required dependency.

Error Handling
==============

All JMAP errors extend :class:`~caldav.jmap.error.JMAPError`, which itself extends
:class:`~caldav.lib.error.DAVError`.  Existing CalDAV error handlers will catch JMAP
errors too if they catch ``DAVError``.

.. code-block:: python

    from caldav.lib.error import DAVError
    from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError, JMAPMethodError

    try:
        event_id = client.create_event(calendar_id, ical)
    except JMAPAuthError:
        print("Authentication failed (401/403)")
    except JMAPCapabilityError:
        print("Server does not support urn:ietf:params:jmap:calendars")
    except JMAPMethodError as e:
        print(f"Server rejected the request: {e.error_type} — {e.reason}")
    except DAVError as e:
        print(f"Protocol error: {e}")

The three specific error classes:

* :class:`~caldav.jmap.error.JMAPAuthError` — HTTP 401 or 403.  JMAP sends no
  401-challenge, so this is always a hard failure.
* :class:`~caldav.jmap.error.JMAPCapabilityError` — the server's Session object
  does not advertise ``urn:ietf:params:jmap:calendars``.
* :class:`~caldav.jmap.error.JMAPMethodError` — a JMAP method call returned an error
  response.  The ``error_type`` attribute holds the :rfc:`8620` error type string
  (e.g. ``"invalidArguments"``, ``"notFound"``, ``"stateMismatch"``).

Configuration File
==================

The JMAP client reads from the same configuration file as the CalDAV client.
Connection parameters use the ``caldav_`` prefix:

.. code-block:: yaml

   ---
   default:
       caldav_url: https://jmap.example.com/.well-known/jmap
       caldav_username: alice
       caldav_password: secret

With the file in place, no arguments are needed:

.. code-block:: python

    client = get_jmap_client()

JMAP and CalDAV settings can coexist in the same file using separate named sections:

.. code-block:: yaml

   ---
   default:
       caldav_url: https://caldav.example.com
       caldav_username: alice
       caldav_password: secret

   jmap:
       caldav_url: https://jmap.example.com/.well-known/jmap
       caldav_username: alice
       caldav_password: secret
       protocol: jmap

.. code-block:: python

    from caldav.jmap import get_jmap_client
    client = get_jmap_client(config_section="jmap")

See :doc:`configfile` for file locations, section inheritance, and other options.

API Reference
=============

* :doc:`caldav/jmap_client` — :class:`~caldav.jmap.client.JMAPClient` and
  :class:`~caldav.jmap.async_client.AsyncJMAPClient` full method reference
* :doc:`caldav/jmap_objects` — :class:`~caldav.jmap.objects.calendar.JMAPCalendar`,
  :class:`~caldav.jmap.objects.event.JMAPEvent`,
  :class:`~caldav.jmap.objects.task.JMAPTask`,
  :class:`~caldav.jmap.objects.task.JMAPTaskList`, and error classes
