jmaplib
A complete, capability-driven JMAP client for Python.
pip install jmaplib
import jmap
The distribution is
jmaplib; the module isjmap. Same split aspython-dateutil→dateutil.
Why
Python has no JMAP client that covers the protocol. The one maintained option is
mail-only and GPL-3.0. jmaplib aims at the whole ecosystem — Mail, Submission,
Vacation, Contacts, Calendars, FileNode, Sieve, Quota, Blob, MDN, push over
EventSource and WebSocket, and Principals/Sharing — under MIT.
Capability-driven by design
The library reads the server's Session resource and adapts to what that server
actually advertises. It will not put a URN on the wire that the server did not
offer, it derives using from the calls you make, and it enforces the server's
own advertised limits (maxCallsInRequest, maxObjectsInGet, maxSizeUpload)
before sending — auto-batching and auto-chunking where that is safe, and raising
where it is not.
A capability is data, not a class hierarchy — one CapabilitySpec per URN,
describing its data types and methods:
from jmap.capabilities.registry import Registry
from jmap.capabilities.core import CORE
registry = Registry()
registry.register(CORE)
active = registry.resolve(session, account_id) # per account, not per connection
active.supports("Email/get") # method-granular, not capability-granular
active.using_for(["Email/get"]) # derived, then intersected with reality
active.unknown_urns # advertised but unrecognised — surfaced, never dropped
Four rules fall out of that, each of which exists because a real server misbehaves without it:
- Resolution is per account.
accountCapabilitiesis a second map, not a subset of the session-level one. Stalwart advertisesurn:stalwart:jmaponly at account level, so the answer is their union. usingis derived, then hard-intersected. Under-declaring degrades silently (RFC 8620 §1.8); over-declaring makes Stalwart reject the entire request withnotRequest, killing every unrelated call batched alongside it.- Capability presence ≠ method presence. The specs say so outright: RFC 9404
§3.1 has a server advertise
urn:ietf:params:jmap:blobwith an emptysupportedTypeNameswhen it implements noBlob/lookupat all. - Properties can pull in a capability too.
urn:ietf:params:jmap:smimeverifyadds properties but no methods, so derivation cannot look at method names alone.
Status
1.0.0 — the whole ecosystem. See CHANGELOG.md.
| Milestone | Scope | State |
|---|---|---|
| M1 | I/O-free protocol kernel | done |
| M2 | Capability registry, auth, transports, sync + async shells | done |
| M3 | Mail (RFC 8621), blobs → 0.1.0 | done |
| M4 | Sync engine: change following, query views, state cursors | done |
| M5 | Blob (RFC 9404), Quota (RFC 9425), Sieve (RFC 9661) → 0.2.0 | done |
| M6 | Push: EventSource, PushSubscription, VAPID, WebSocket → 0.3.0 | done |
| M7 | OAuth acquisition (PKCE, device flow), SRV discovery | done |
| M8 | Contacts (RFC 9610 + vendor), Sharing (RFC 9670) | done |
| M9 | Calendars (draft-27) — experimental | done |
| M10 | FileNode (draft-14) — experimental → 0.7.0 | done |
| M11 | MDN (RFC 9007), S/MIME, conformance matrix → 1.0.0 | done |
Two caveats worth stating plainly.
Calendars and FileNode are experimental and do not resolve unless you pass
experimental=True. They track Internet-Drafts; the calendars draft is blocked onjscalendarbisand its own normative reference is already a revision stale, so its wire names can still change.jmap.SPEC_REVISIONSpublishes exactly what this build targets.The JSCalendar and JSContact bodies are carried, not modelled. A
CalendarEventis a JSCalendar Event and aContactCardis a JSContact Card; both round-trip losslessly through the model'sextraand are readable by exact wire name (event.jscalendar("recurrenceRule")). What is modelled is the JMAP layer around them, which is where the traps live. Field-by-field models for those two vocabularies are a codegen job and are not done.The live suite gates CI. Every push bootstraps a real Stalwart v0.16 headlessly and runs
tests/integration/against it; seedocs/stalwart-spike.mdfor the procedure and what had to be discovered to make it work. Its first green run was worth the trouble — it found five defects no fake server could have, four of them in the library: a creation reference that never serialised, a back-reference nested where the wire format cannot express one, blob upload unable to resolve an account on any real server, and an event source that could not stay open past five seconds. Point it at your own server withJMAP_TEST_URL,JMAP_TEST_USERandJMAP_TEST_PASS.
Capability namespaces
with client.batch() as batch:
query = batch.mail.email.query(filter={"inMailbox": inbox})
emails = batch.mail.email.get(ids=query.ref_ids(), properties=["subject"])
emails.result.items[0].subject # typed Email, one request
Namespaces come from the same resolution the raw path uses, so a mail-only server
has no client.calendars at all — an AttributeError at your call site rather
than a namespace that exists and fails on every call.
Blobs, two ways
The RFC 8620 way is not JMAP at all: blobs move over plain HTTP to URLs the Session advertises as templates, so they are not batchable.
uploaded = client.upload(pdf_bytes, content_type="application/pdf")
client.download(uploaded.blob_id, name="invoice.pdf")
maxSizeUpload is checked before sending — a 60 MB attachment against a 50 MB
limit fails in microseconds rather than after streaming 60 MB.
Where the server offers urn:ietf:params:jmap:blob (RFC 9404) there is a second
way, and it is batchable — which matters whenever something else in the same
request needs the new blobId:
from jmap.models.blob import BlobUpload, DataSource
with client.batch() as batch:
batch.blob.blob.upload(
create={"s": BlobUpload(data=[DataSource.text(script)], type="application/sieve")}
)
batch.sieve.sieve_script.set(create={"A": {"name": "filters", "blobId": "#s"}})
"#s" is a creation reference, resolved by the server against createdIds. It
is a different mechanism from the #argument result references used elsewhere,
and it is the only one legal inside a /set object — RFC 8620 §3.7 result
references are top-level arguments only.
Sources concatenate, and a blobId source with offset and length splices an
existing blob without the octets ever leaving the server:
DataSource.blob("#whole", offset=2, length=3)
Two things about reading blobs back are easy to get wrong, so the models handle
them: size is the size of the whole blob even under a range request (so
comparing it against len(data) is not how you detect a short read — isTruncated
is), and data:asText comes back null with isEncodingProblem when the
selected octets are not valid UTF-8, which is indistinguishable from an empty blob
unless you look at the flag. Blob.data reads whichever representation arrived.
Content moves inside the JSON request here, so it counts against maxSizeRequest;
RFC 9404 §4.1 recommends the upload endpoint past a megabyte. Blob/copy stays
with :core because RFC 8620 owns it, so the same type has different methods in
client.core.blob and client.blob.blob — the split is the spec's, not ours.
Quotas and Sieve scripts
with client.batch() as batch:
quotas = batch.quota.quota.get(ids=None)
quotas.result.items[0].remaining # headroom, floored at zero
There is no Quota/set — usage is server-computed — so client.quota.quota has
no .set attribute at all. Quota/changes carries updatedProperties, and its
null reads backwards from the obvious: RFC 9425 §4.3 requires it whenever the
server cannot tell what changed, so it means "fetch everything", not "nothing
changed". fetch_all_properties says which you have.
Sieve scripts are metadata plus a blobId. Activation is a side effect of /set
rather than a property, because isActive is server-set and at most one script may
hold it:
with client.batch() as batch:
batch.sieve.sieve_script.activate(script_id)
Destroying the active script needs two /set calls — RFC 9661 §2.4 requires the
deactivation to be separate — which is a batch, not a combined call. And
maxSizeScriptName counts octets, not characters: check_name() measures the
UTF-8 encoding, so a four-character CJK name is twelve.
RFC 9661 defines no SieveScript/changes at all, even though the type is
registered as usable for state change. Push can tell you scripts changed while
giving you no way to ask what — re-running /get is the answer, which is fine for
a handful of scripts.
Staying in sync
The library holds no cache of its own. It computes deltas and hands them over;
what to persist is the application's decision, so the only seam is a StateStore.
from jmap.sync import ChangeStream, InMemoryStateStore
stream = ChangeStream(client, "Email", store=my_store)
changes = stream.catch_up() # follows hasMoreChanges to the end
changes.touched # created + updated, deduplicated
Two things this gets right that are easy to get wrong:
hasMoreChangesmeans there is more. Reading one page and stopping loses the tail silently, because the state string still advances.catch_up()follows it to the end;pages()yields them one at a time for large mailboxes.cannotCalculateChangesis not retryable. RFC 8620 §5.2 requires the cache to be invalidated and re-downloaded, so it raisesResyncRequiredErrorrather than passing through as a generic method error — the recovery is different.
Delivery is at least once: the cursor advances only when you come back for the next page, so a page being processed when the process dies arrives again. A caller can absorb duplicates; it cannot recover changes it never saw.
QueryView keeps a cached result list current via Foo/queryChanges:
view = QueryView.from_query(spec, query_response)
view.apply(query_changes_response) # splices the delta in
view.known_ids # what you actually hold
The cached list is sparse — RFC 8620 §5.6 models it as ["id1", null, "id3"],
where the nulls are positions you know exist but never fetched, and they are what
keep the indices meaningful. Removals are applied before insertions because the
added indices describe the list after removals, and an id appearing in both
arrays is a move, not a delete. The implementation reproduces the RFC's worked
example exactly, and that example is a test.
Push
Three transports, one idea: a StateChange names which types moved in which
accounts and nothing else. So whichever way it arrives — and whether or not some
are dropped — the follow-up is the same /changes call, and the client converges.
Push is an optimisation, never a second source of truth.
from jmap.push import EventSourceClient
source = EventSourceClient(client, types=("Email", "Mailbox"), ping=30)
for event in source.listen(): # reconnects, resuming each time
for account, states in event.outdated(my_cursors).items():
... # only what actually moved
outdated() is the method that matters. A StateChange arriving does not mean
something changed for you — compare it against what you hold, or you re-fetch
types that never moved. And RFC 8620 §7.1 notes a notification can land while your
own /set is still in flight, so matches() recognises your own write.
Two rules here are silent when broken, so the library encodes both:
- A ping is not a cursor. RFC 8620 §7.3 forbids a ping from setting an event id. A client tracking "the last thing I received" resumes from a keep-alive and skips every change before it. The resume cursor is unmoved by pings.
closeafter=stateending the response is success. Buffering proxies otherwise hold a stream back indefinitely, so the server ending it after one event is what was asked for.listen()reconnects rather than backing off.
ping also decides how long the client will wait on a silent stream. An event
source is idle by design, so it does not inherit the HTTP client's read timeout
— httpx defaults that to five seconds, which would hang up on every healthy
connection. A requested ping is a promise of traffic on a schedule and becomes the
deadline — allowing for the fact that §7.3 lets a server round a request up to a
minimum of 30 seconds, so asking for 5 and hanging up at 5 would kill a perfectly
conformant connection. With no ping requested there is no promise at all and the
client waits indefinitely, which is what "notify me when something changes" means.
Registering a URL instead is a three-step dance, and the middle step is the
security property: the server pushes a PushVerification and makes no further
request to that URL until the code comes back — which is what stops a
subscription being used to aim a JMAP server at a third party. The verification
can arrive before the /set response that created the subscription (§7.2.3), so
PendingVerification records codes as they land and claims them later, in
whichever order the two actually happen.
Over a WebSocket (RFC 8887) the same socket carries requests, responses, errors
and notifications, distinguished only by @type. Responses may come back out of
order — §4.3.2 says so — so WebSocketProtocol correlates by request id rather
than assuming FIFO. Its pushState token makes a reconnect cost one exchange
instead of a /changes call per type.
VAPID (RFC 9749) adds one field and one obligation: rotating the application
server key destroys subscriptions tied to the old one, and nothing raises when it
happens — notifications just stop. needs_recreating(session, key) is how you
find out.
Signing in
jmaplib can find the server and get a token without being told either.
from jmap.auth import OAuthClient
metadata = OAuthClient.discover(challenge.resource_metadata) # RFC 9728 → RFC 8414
with OAuthClient(metadata, client_id="...") as oauth:
token = oauth.authorize(scope="urn:ietf:params:jmap:core") # PKCE, loopback
client = JMAPClient.discover("alice@example.com", auth=BearerAuth(token.access_token))
JMAPClient.discover tries _jmap._tcp SRV records before
https://<domain>/.well-known/jmap, because the well-known guess alone is not
enough — Fastmail answers 404 there.
Two things in the OAuth path are security properties rather than conveniences, and both are the kind that work fine against a cooperative server:
- The well-known segment is inserted, not appended. RFC 8414 §3.1 puts
/.well-known/oauth-authorization-serverbetween host and path. Appending happens to work for single-tenant deployments, which is exactly why that bug survives to production. - The returned
issueris checked against the one the URL was built from (§3.3). Without it, any host that merely answers that path can nominate whichever token endpoint it likes.
Only S256 PKCE is used. RFC 7636 also defines plain, where the challenge is
the verifier — which defeats the point for precisely the clients that need it, so
a server advertising neither is refused rather than downgraded to.
Sharing
from jmap import sharing
principals = sharing.principal_account(client.session, data_account_id)
with client.batch() as batch:
batch.calendars.calendar.set(update={cal_id: sharing.grant(bob, {"mayReadItems": True})})
grant() and revoke() build pointer patches. Assigning shareWith wholesale
revokes everyone absent from the new map — the difference between "add Bob" and
"make Bob the only person with access". The owning Principal must never appear in
the map at all (RFC 9670 §4), and the account you address the /set at is not
the account the Principal ids come from: principal_account() is that lookup.
Contacts, two models
RFC 9610 gave contacts AddressBook and ContactCard. Before it, Fastmail and
Cyrus shipped Contact and ContactGroup — and those are gated by vendor URNs,
not by urn:ietf:params:jmap:contacts. So they are three capabilities rather than
two flavours of one, a server may advertise several at once, and using derivation
handles it without being told:
batch.contacts.contact_card.query(filter={"name/given": "Alice"}) # RFC 9610
batch.fastmail_contacts.contact.query(filter={"text": "Alice"}) # pre-RFC
Calling Contact/get with only the IETF URN in using earns unknownMethod from
a server that fully implements it, and the error names the method — which reads
as "this server has no contacts" rather than "you declared the wrong capability".
What does this server actually do?
Capability presence does not imply method presence — the specs say so outright,
and RFC 9404 §3.1 describes a server advertising urn:ietf:params:jmap:blob while
implementing no Blob/lookup at all. So the honest answer is a matrix:
python -m jmap.testing.conformance https://example.com/.well-known/jmap \
--user alice@example.com --password ... --markdown
It costs no method calls — everything comes from the Session — and it keeps three states apart that reports usually collapse into "supported":
| meaning | |
|---|---|
| advertised, modelled | typed calls available |
| advertised, not modelled | a vendor URN or a newer spec; still reachable via batch.add |
| modelled, not advertised | this build speaks it; this server does not offer it |
That last row is the one that answers "why is this feature missing", and it is absent from most conformance reports.
/get chunks itself; /set refuses to
maxObjectsInGet caps how many ids one call may name, and a client holding a few
thousand ids from a /query exceeds it routinely. /get is safe to split — it
changes nothing — so it happens automatically and you still get one handle:
emails = batch.add("Email/get", {"ids": three_thousand_ids}) # → 30 calls
emails.result.items # merged back
The one thing that doesn't recombine cleanly is the state string. Each chunk
reports the state it was answered from, and if the data changed mid-read the
merged result would be a mix of two points in time — undetectable afterwards. So
mismatched states raise TornReadError rather than handing back a torn read.
/set is not chunked, for the reason given above: splitting it would break
the single ifInState that makes it atomic. It raises instead.
Header queries own both halves of the round trip
A header is fetched by asking for a property whose name encodes the request —
and RFC 8621 §4.1.2 says the server echoes that name back exactly as sent.
Request header:subject and the answer is keyed header:subject; request
header:Subject and it is keyed header:Subject. Ask one way, read the other,
and you silently get None.
from jmap.models.mail.headers import text, addresses, raw
subject = text("Subject") # header:Subject:asText
subject.property_name # what to request
subject.read(email) # …and the key it comes back under
raw("Received", all=True) # header:Received:all — every hop
addresses("To") # header:To:asAddresses
Without :all you get the last occurrence, not the first and not a list.
Creation constraints are checked before sending
Email/set is the one place the object you send is not shaped like the one you
get back. The server refuses with invalidProperties, which names the property
but not the rule — so the same mistake is easy to make twice:
from jmap.models.mail.create import validate_email_create
validate_email_create(
{
"mailboxIds": {"mb1": True},
"textBody": [{"partId": "t", "type": "text/plain"}],
"bodyValues": {"t": {"value": "hello"}},
}
)
It catches server-assigned properties (id, blobId, threadId, size), the
read-only headers list, describing the body both ways at once, empty
mailboxIds, malformed keywords, body parts with both or neither of
partId/blobId, and bodyValues entries that are unreferenced or missing.
It is deliberately a subset of §4.6 — anything needing server state (does the
mailbox exist? is the blob still live?) is left to the server.
The method surface matches the server
Six method shapes cover almost all of JMAP, so they are written once and parameterised by the object model. But a type does not get all six — and rather than expose them everywhere and fail at run time, each façade is composed from the capability spec:
| Type | Surface |
|---|---|
Email |
get changes query query_changes set copy |
Mailbox |
get changes query query_changes set |
Thread |
get changes — threads are derived, not stored |
VacationResponse |
get set — a singleton |
So thread.query(...) is an AttributeError at your call site rather than an
unknownMethod from the server. Responses are typed to match: Email/get
returns a GetResponse[Email], Email/set a SetResponse[Email].
A /set half-succeeds by design, so its per-object failures are values rather
than exceptions — raising would discard the objects that did change:
result = emails.set(create={"d1": {...}}).result
result.created_id("d1") # server-assigned id, or None
result.creation_errors # {"d2": SetError(type="overQuota")}
Mail is three capabilities, not one
RFC 8621 defines …:mail, …:submission and …:vacationresponse separately,
and servers advertise them independently — a read-only archive account may have
mail without submission. Merging them would put :submission in using for a
plain Email/get and, on a server that lacks it, fail the entire request.
Identity therefore lives under :submission, not :mail: it exists to name
what you may send from.
Documentation
Task-oriented guides live in docs/:
| Getting started | Install, connect, first request, async. |
| Capabilities | What the server advertises decides what you can call. |
| Batching and references | One request, many calls, chaining results. |
| Mailboxes, searching, reading, composing, sending. | |
| Blobs | Binary data, digests, lookup, copying. |
| Staying in sync | Change streams, query views, state cursors. |
| Push | Event source, subscriptions, VAPID, WebSocket. |
| Authentication | Presenting credentials, and acquiring them. |
| Errors | Four failure levels, and which are safe to retry. |
| Other capabilities | Quota, Sieve, contacts, calendars, files, sharing, MDN, S/MIME. |
| Testing your own code | The fake server that ships with the package. |
The rest of this README is design rationale: why the library is shaped the way it is. If you want to use it, start with the guides.
Usage
import httpx
from jmap.auth import BasicAuth
from jmap.client import JMAPClient
with JMAPClient.connect(
"https://mail.example.com/.well-known/jmap", # redirects are followed
auth=BasicAuth("alice@example.com", "app-password"),
) as client:
client.echo(hello="world")
A JMAP request is a batch, so batching is the normal path rather than an optimisation. Calls queued in one block travel in one request, which is what makes back-references natural:
with client.batch() as batch:
query = batch.add("Email/query", {"filter": {"inMailbox": inbox}})
emails = batch.add("Email/get", {"ids": query.ref_ids()}) # resolved server-side
print(emails.result["list"]) # readable once the block exits
A back-reference replaces a whole argument, because that is all the wire format
can express: ids becomes #ids. To point at an object being created in the
same request — a draft you are submitting as you write it — the mechanism is a
creation reference, which is an ordinary string and so works at any depth:
from jmap import CreationRef
with client.batch() as batch:
batch.mail.email.set(create={"draft": {...}})
batch.submission.email_submission.set(
create={"send": {"emailId": CreationRef("draft"), "identityId": identity}}
)
Putting a ResultRef inside a create object instead raises
NestedResultRefError locally, naming the argument and pointing here — rather
than failing as a TypeError from inside the JSON encoder.
The async client is a mirror — same names, same behaviour, await in front:
from jmap.aio import AsyncJMAPClient
async with await AsyncJMAPClient.connect(url, auth=auth) as client:
async with client.batch() as batch:
query = batch.add("Email/query", {})
emails = batch.add("Email/get", {"ids": query.ref_ids()})
Everything either client decides — what using needs, how to split a batch,
whether a failure may be retried — is computed by the same I/O-free kernel, so
the two cannot disagree about protocol behaviour.
Failures are caught locally where possible
These all raise before anything reaches the wire:
| Situation | Error |
|---|---|
| Method no advertised capability provides | UnsupportedMethodError |
| Mutation aimed at a read-only account | ReadOnlyAccountError |
No accountId, and no single account to resolve one |
NoAccountError |
Back-reference nested inside a create object |
NestedResultRefError |
| Requesting a property the server never returns | CapabilityFieldError |
/set larger than maxObjectsInSet |
CapabilityFieldError |
Reference chain that cannot fit maxCallsInRequest |
BatchTooLargeError |
An oversized /set raises rather than being split, because splitting it would
break the single ifInState that makes it atomic.
Testing against the library
The fake server ships with the package, so downstream tests need no network:
from jmap.testing import FakeJMAPServer, ServerQuirks
server = FakeJMAPServer(quirks=ServerQuirks(unknown_using_is_not_request=True))
server.respond("Email/get", {"list": [{"id": "m1"}], "state": "s"})
client = JMAPClient.connect(url, auth=auth, http=httpx.Client(**server.client_kwargs()))
It resolves back-references with the library's own pointer evaluator, so the
test exercises what the client will really do, and ServerQuirks reproduces real
deviations — Stalwart answering an unknown using URN with a batch-destroying
notRequest, capabilities appearing only in accountCapabilities.
Retry safety
JMAP has no idempotency key, so "retry on failure" is not a policy — it is a way
to create duplicate drafts and double-send mail. A POST carrying Email/set that
timed out may have fully applied, and the client cannot tell.
So failures are classified by whether the request provably never reached application:
| Situation | Verdict |
|---|---|
| Connection never established | safe — nothing was sent |
429, 503, request-level …:error:limit |
safe — rejected whole, no method ran |
| Timeout after the bytes went out | only if the batch cannot change state… |
| 5xx | …or every mutation carried ifInState |
| 4xx | never — understood and refused |
ifInState is what makes the middle rows safe: RFC 8620 §5.3 makes a guarded
/set fail with stateMismatch if anything changed, so a retry of an
already-applied request is rejected rather than duplicated.
Authentication
RFC 8620 deliberately defines no auth scheme — the Session resource is simply an
authenticated endpoint — so the library presents credentials and reads the
WWW-Authenticate challenge that comes back.
from jmap.auth import BasicAuth, BearerAuth, CallableAuth, OAuth2Auth, OAuth2Token
BasicAuth("alice@example.com", "app-password") # self-hosted, Fastmail app passwords
BearerAuth("api-token") # API tokens, static access tokens
CallableAuth(lambda: sign_request()) # escape hatch for anything else
OAuth2Auth(OAuth2Token("access", refresh_token="r"), refresh=renew, store=my_store)
Two behaviours here are correctness rather than preference:
- Basic and Bearer never retry a 401. The credential would be byte-identical the second time, so a retry cannot succeed — it can only look like brute force, and Stalwart fail2bans repeated failures.
- Refresh is single-flight and persists before it discards. Fastmail rotates
the refresh token on every use and revokes the whole grant if an old one is
replayed, so concurrent 401s must produce exactly one refresh, and the new
token must reach your
TokenStorebefore the old one is dropped.
Interactive OAuth acquisition (PKCE, device flow, RFC 9728/8414 discovery) lands after 0.1.0; for now you bring a token.
Specs tracked: RFC 8620, 8621, 8887, 9007, 9219, 9404, 9425, 9553, 9555, 9610,
9661, 9670, 9749, plus draft-ietf-jmap-calendars-27 and
draft-ietf-jmap-filenode-14 (both shipped behind an experimental flag and
excluded from the SemVer promise; see jmap.SPEC_REVISIONS).
Development
uv sync --all-extras
uv run pytest # integration tests are deselected by default
The full gate, which is what CI runs:
uv run ruff check src/ tests/
uv run ruff format --check .
uv run mypy --strict src/ && uv run mypy tests/
uv run pyright src/
uv run pyright --verifytypes jmap --ignoreexternal # must be 100%
uv run lint-imports # layering contracts
uv run pytest --cov # must be 100%
Running against a live server
Integration tests are deselected by default; -m integration selects them. They
need a server and two accounts — the second one exists so the delivery test
(alice sends, bob receives) runs rather than skips, and that assertion is the one
thing the fake server cannot make.
Against a server you already have, no bootstrap is involved:
JMAP_TEST_URL=https://mail.example.com/.well-known/jmap \
JMAP_TEST_USER=alice@example.com JMAP_TEST_PASS=... \
JMAP_TEST_USER2=bob@example.com JMAP_TEST_PASS2=... \
uv run pytest -m integration
Point JMAP_TEST_URL at /.well-known/jmap rather than the API URL: that way
the redirect and the survival of the Authorization header across it are
exercised for real. Everything the server does not implement skips with a reason
naming the method, so a partial server still produces a useful run.
A TLS error from an http:// URL is not a contradiction. Only the session
document is fetched from the URL you supply; every call after that goes to the
apiUrl the server advertises, and a server that does not know its own public
URL will advertise https://<hostname> regardless of how you reached it. For
Stalwart that means STALWART_PUBLIC_URL is unset. Two env vars handle a
certificate no public CA vouches for, which is the normal case for a self-hosted
server:
JMAP_TEST_CA=/path/to/cert.pem |
Trust this certificate. Verification still happens, so a wrong host or an expired certificate still fails. Prefer this. |
JMAP_TEST_INSECURE=1 |
Verify nothing. Also disables hostname checking, which is what catches a server advertising https://localhost when it means something else. |
To raise a throwaway Stalwart instead, the recipe CI uses:
docker run -d --name jmaplib-test -p 18080:8080 \
-e STALWART_PUBLIC_URL=http://localhost:18080 \
-e STALWART_RECOVERY_ADMIN="admin:$STALWART_RECOVERY_ADMIN_PASS" \
-v jmaplib-test-data:/var/lib/stalwart \
stalwartlabs/stalwart:v0.16.17
STALWART_URL=http://localhost:18080 STALWART_CONTAINER=jmaplib-test \
STALWART_RECOVERY_ADMIN_PASS=... ./scripts/stalwart-bootstrap.sh
The script prints alice's and bob's generated passwords at the end. Two details
are load-bearing: no config file is mounted, because bootstrap mode triggers
only when the server finds none, and the volume is a named volume, because the
image runs as UID/GID 2000 and cannot write a bind mount. STALWART_PUBLIC_URL
must match the published port or the session advertises URLs nothing can reach.
The script completes setup and restarts the container it is given. Point it only at a throwaway instance — never at a server holding anything you want to keep. Pick a host port nothing else uses, too: Stalwart defaults
socket_reuse_portto true, so a second server binds an occupied port silently and the kernel then splits requests between the two.
python -m jmap.testing.conformance <url> --user … --password … --markdown
renders what any server actually supports, method by method.
Coverage is gated at 100%, not aspirational. The kernel is pure functions
over plain data, so anything uncovered is either dead code or a branch nobody
thought through — both worth failing the build over. lint-imports enforces the
layering: core may not import httpx, asyncio or anyio, and capabilities may
not import the client.
Benchmarks
uv run python -m benchmarks.run # all cases
uv run python -m benchmarks.run --profile "e2e query" # cProfile one of them
Payloads are shaped like what a real server returns — a Stalwart-shaped session,
an Email/get of a hundred messages with the full RFC 8621 §4.1 property set —
because a benchmark over {"id": …, "subject": …} measures nothing that happens
in production. --save records a baseline and --compare diffs against it,
exiting non-zero past a 5% regression. See benchmarks/README.md.
Two things worth knowing before reading a number. The statistic is the minimum
of N repeats, not the mean: timing noise is one-sided, so the minimum is both the
truest estimate and the stable one. And the json.loads (floor) / json.dumps (floor) cases are the stdlib doing the same work without I-JSON enforcement —
the gap to them is the cost of conformance, currently about 1.8× on parse.
Performance claims in this project are expected to come with a measurement. The
one time that rule was skipped, dumps got 8% slower while every test still
passed, because True fell through the fast scan's type ladder and sent every
payload down the precise-but-slow path. Tests cannot see that — both paths give
the same answer — so TestTheFastPathIsActuallyTaken asserts the path directly.
License
MIT
Release files for jmaplib 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| jmaplib-1.1.0.tar.gz | 389.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| jmaplib-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 645.4 kB
Release files / jmaplib-1.1.0.tar.gz
| Download URL | jmaplib-1.1.0.tar.gz |
|---|---|
| Size | 389.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
305e7b81e164345fa30906c354787a3fe7f269ef9f65eff9bb07d279b9bc0280
|
|
BLAKE2b-256 checksum How to use checksums |
664c00f60d94f666c0f2d889378dc8a484748de033f0ae4c1374fccc55442041
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 13, 2026.
Transparency logRelease files / jmaplib-1.1.0-py3-none-any.whl
| Download URL | jmaplib-1.1.0-py3-none-any.whl |
|---|---|
| Size | 255.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5aef2b459ad41a999b834e042602b258b9ddf15afb342e07b16528549a1bd3ea
|
|
BLAKE2b-256 checksum How to use checksums |
1b057b333a32f7530b36b272d649a5a133c9e3cee9c2c800950db29c1c239be4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 13, 2026.
Transparency log