JMAP#

The jap 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#

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)

get_jmap_client() reads configuration from the same sources as 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:

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

# 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 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):

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

Listing Calendars#

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

Each item is a 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 caldav.collection.Calendar:

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 get_sync_token() results, for example), you can also use the lower-level client methods directly:

# 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 search() on a calendar object, mirroring the CalDAV caldav.collection.Calendar.search() interface:

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:

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

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

A typical pattern is to persist the token between runs:

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, get_task_lists() will raise JMAPMethodError.

# 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 create_task(): description, start, due, time_zone, estimated_duration, percent_complete, progress, priority.

Each item from get_task() is a 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 get_task_lists() is a JMAPTaskList with fields id, name, description, color, is_subscribed, my_rights, sort_order, time_zone, role ("inbox", "trash", or None).

Async API#

AsyncJMAPClient mirrors every method of JMAPClient as a coroutine. Use it as an async with context manager (sync with is not supported):

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 JMAPError, which itself extends DAVError. Existing CalDAV error handlers will catch JMAP errors too if they catch DAVError.

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:

  • JMAPAuthError — HTTP 401 or 403. JMAP sends no 401-challenge, so this is always a hard failure.

  • JMAPCapabilityError — the server’s Session object does not advertise urn:ietf:params:jmap:calendars.

  • 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:

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

client = get_jmap_client()

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

---
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
from caldav.jmap import get_jmap_client
client = get_jmap_client(config_section="jmap")

See Config file format for file locations, section inheritance, and other options.

API Reference#