Skip to content

refactor(mail,calendar): migrate all JMAP calls to jmaplib - #706

Merged
s-aga-r merged 64 commits into
frappe:developfrom
s-aga-r:migrate/jmaplib
Oct 2, 2026
Merged

s-aga-r merged 64 commits into
frappe:developfrom
s-aga-r:migrate/jmaplib

Conversation

@s-aga-r

@s-aga-r s-aga-r commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Replaces the hand-rolled per-user JMAP layer — the suite/mail/jmap/ service/model package behind Mail, Calendar and Contacts — with jmaplib (jmaplib[push]~=3.0.1, import name jmap). The services/models concepts are gone; what remains is one flat helper module plus one calendar-domain module:

  • suite/mail/jmap.py — client construction (get_jmap_client / get_account_client / account_view), the Redis session cache (same jmap:sessions hash), MailServerUnavailableError translation (transport errors and 502/503/504 → 503; the frontend keys on the class name), TTL-cached identity/mailbox lookups under their existing names, and the shared helpers: chunked_set / chunked_get, get_across_accounts, get_email_state, upload_blobs / download_blobs, build_email_draft, build_submission_envelope, format_set_error / format_method_error / get_set_error_message.
  • suite/calendar/jmap_events.py — the calendar domain logic that used to live in CalendarEventService, including develop's query_around, occurrences_from, set_instance_participation_status, remove_overrides and set_overrides.

Call sites use jmaplib's client.batch() builders directly. Stalwart management is out of scope: develop moved it to suite_cloud, so the management port this PR once carried is dropped and the Suite Cloud client comes in as develop has it.

Behavioural notes / things that changed on purpose

  • Typed parse responses. CalendarEvent/parse, ContactCard/parse and Email/import go through jmaplib's typed builders; the exchange doctypes and event notifications were adjusted.
  • None vs omitted. Stalwart rejects an explicit null top-level method argument with notRequest; the old transport silently dropped them. Optional arguments go through omit_none(...).
  • Oversize /set and torn /get. jmaplib refuses a /set over maxObjectsInSet instead of splitting it, and raises TornReadError when an auto-chunked /get sees a state change mid-read. Bulk paths use chunked_set / chunked_get (the latter keeps the old "concatenate chunks" semantics so a concurrent mailbox change can't abort a large export/import).
  • Expanding calendar queries are queued raw. draft-ietf-jmap-calendars has an expanding CalendarEvent/query carry one bare FilterCondition naming both after and before, and jmaplib's typed builder enforces that. develop's search (query_around) and shared-calendar grid expand with AND/OR filters and one-sided windows, which Stalwart accepts and which no bounded single condition reproduces, so jmap_events sends those through batch.add with develop's exact wire form.
  • Calendar/get names its properties. Stalwart's default set omits isVisible, which read as every calendar hidden; every calendar read passes CALENDAR_PROPERTIES.
  • Pre-send refusals. jmaplib 2.0 refuses, before the call, a mailbox or Sieve script name the server's advertised limits rule out (CapabilityFieldError); the user-facing /set handlers render it like the per-object error it replaces. maxDelayedSend is read through SubmissionCapability: 0 now means the server holds nothing, not 30 days.
  • Push handling uses jmaplib's PushKeyPair (RFC 8291, one aes128gcm record) for key generation, validation and decryption, and read_push for the body: the endpoint dispatches on the typed StateChange / PushVerification / CalendarAlert it returns and refuses anything else. Keys generated from now on are stored unpadded, as RFC 8291 writes them; padded keys already stored keep working.
  • A refused call is retried. A method-level error on Email/set or EmailSubmission/set marks the row Failed to Draft / Failed to Submit with a retry scheduled, like a refused object; before (and on develop) it sat Drafted or Failed with no retry time, which the pending-mail worker never picks up. When a refused draft takes its submission down with it, the draft's refusal stays the row's status and message.
  • An invite resolves to its own event. Adding an invite attachment returns the id of the first event in the file - the one the reader is shown and answers - rather than the first id created or found, so an RSVP on a multi-event file cannot land on another event (the index-lag fallback included). Only that event failing to reach the calendar is an error; another event in the file the server refuses is logged and left out.
  • Mail Queue _response is the structured create/notCreated payload; error_message tolerates both the old methodResponses shape (rows survive 3 days and are retryable) and the new one.
  • Per-account isolation in the all-accounts unread badge — one failing account no longer 500s the whole response.
  • Method-level errors are messages, not 500s. A refused /set or /query on the calendar delete and fetch paths renders through format_method_error like the create and update paths always did; develop's transport read the error body as an empty result and reported success.
  • One retry. RetryPolicy(max_attempts=2, max_retry_after=5.0): jmaplib re-sends a batch only when the request provably never reached the server (a connection failure, a 429 or a 503) or a literal ifInState guards it, so a blip heals without a duplicate write; a longer Retry-After is not waited out.
  • Limits checked before sending. Mail Queue refuses attachments over the account's maxSizeAttachmentsPerEmail when the row is validated, before any blob is uploaded; a mailbox created or moved deeper than maxMailboxDepth is refused before the call.
  • Sieve scripts travel inside the request. Where the server offers RFC 9404, the script's blob is created by a Blob/upload queued in the same request as the SieveScript/set (or /validate), one round trip fewer per save; without blob management it still goes to the upload endpoint first.
  • One session per client and its account views. A stale session state noticed on any view refreshes the session once for all of them (and resyncs the JMAP Accounts once), instead of once per view within a request.
  • An empty id list fetches nothing. get_events read [] as "every event", so a calendar query that matched nothing answered with unrelated events (pre-existing on develop).
  • Mail Exchange imports validate the archive's destination mailbox ids against the target account before staging anything, and the move-phase error carries the server's real per-object reason.

Verification

  • Unit / fake-server: all 50 mail and calendar unit, FakeJMAPServer and fake-Suite-Cloud modules pass on s2 (bench --site s2 run-tests --app suite --module …), including develop's test_push_subscription_healing (37), test_mail_queue_payload, test_email_draft_body, test_mail_push_sync (re-seamed on the flat modules / jmap.testing.FakeJMAPServer) and the new test_push_encryption.
  • Live (Stalwart via Suite Cloud): all 21 StalwartIntegrationTestCase modules (7 calendar, 14 mail; 176 tests across their classes) pass on s2 against the mail.c1.frappe.email cluster through Suite Cloud sc1, provisioning their own *.example.test domains. That needs Suite Cloud's new Skip Domain Verification setting (frappe/suite_cloud, branch skip-domain-verification): a real Suite Cloud otherwise adds a tenant's domain only once a TXT record proves control and lets accounts onto it only once its DNS records verify, which made-up test domains never can — see suite/mail/tests/docker/README.md.
  • jmaplib side: 2.1.0 adds CalendarAlert to read_push / listen(), which suite's push endpoint is built on. The pin is 3.0.1, whose API is 2.1.0's: the major version marks jmaplib's relicense from MIT to AGPL-3.0-only, the license Suite itself carries.

Deployment notes

  • The old suite/mail/jmap/ directory is removed in git, but stale untracked __pycache__ dirs leave it on disk as a namespace package that shadows the flat module. Rollouts must rm -rf suite/mail/jmap/ (and suite/mail/stalwart/, from before develop's move) and restart processes.
  • Flush the jmap:sessions Redis hash once at deploy — the cached session document shape changed from the pre-jmaplib format.
  • No schema migrations; rollback is git revert + restart + the same flush.

Follow-ups

None outstanding.

🤖 Generated with Claude Code

s-aga-r added 19 commits August 14, 2026 18:28
- chunked_get: concatenate oversized /get chunks like the old client instead of
  failing with TornReadError when the account changes mid-read (exports, bulk reads)
- get_all_inbox_unread_count: a stale/revoked account no longer breaks the badge
  for every account (method-level errors skip that account, as before)
- stalwart: server-down now surfaces as MailServerUnavailableError (503) again for
  session discovery, /api/schema, delivery-token and message-blob downloads
Stalwart's x:Log store orders by id, which is not reliably newest-first (an id-format
change across server upgrades interleaves the epochs) and rejects a timestamp sort —
sort each page for display, keeping the pagination anchor in server order. The
overview's recent-logs block reduces a wider page to the 6 newest for the same reason.
A JMAP archive carries account-local mailbox ids; importing one into a different
account made the server reject the move of every unknown-id email one by one
(invalidProperties: 'mailboxId ... does not exist') — after all emails had already
been staged — while ids that happened to collide with real mailboxes silently
landed mail in the wrong folders. Validate every destination id against a fresh
mailbox read before staging, and surface the server's actual rejection reason
(plus a logged per-reason breakdown) when a move still fails.
Ports the Outbox (suite/mail/api/scheduled.py) and its tests from the
deleted EmailSubmissionService to jmaplib, and carries the RFC 3339
HOLDUNTIL format into build_submission_envelope.
…ex lag

The uid lookup behind add_invite_to_calendar runs on the server's async
search index, so a repeated add issued right after the first could miss
the event and then fail on the server's duplicate-uid rejection. Re-resolve
the uid briefly before failing; the RSVP sync test now waits for the
attendee's copy to become searchable before syncing.
develop moved Stalwart management to suite_cloud, so the management port
(suite/mail/stalwart.py, its admin call sites and tests) is dropped, and the
Suite Cloud client, directory and admin API come in as they are. What
develop added on the deleted service layer is expressed on jmaplib:

- suite/mail/jmap.py gains allow_disabled on get_jmap_client, the parsed
  replyTo / format=flowed text part / always-explicit bodyStructure in
  build_email_draft, get_across_accounts, get_email_state and the explicit
  Calendar/get property list (Stalwart's default set omits isVisible).
- suite/calendar/jmap_events.py gains query_around, occurrences_from,
  set_instance_participation_status, remove_overrides and set_overrides, and
  participants carry scheduleAgent / memberOf. CalendarEvent/query is queued
  raw: the search and the shared-calendar grid expand with operator filters,
  which Stalwart accepts and jmaplib's typed builder refuses.
- Mail Queue's payload models produce the draft rows build_email_draft
  takes; the Outbox, push-subscription healing, mail sync state seeding,
  user-settings account sync and the automation Sieve rebuild call the
  client directly.
- develop's tests that patched the old service seams are re-seamed on the
  flat modules and jmap.testing.FakeJMAPServer.
2.0.0 checks requests before sending and reclasses errors, so the glue
changes with it: refresh_session() now keeps the experimental opt-in, which
the constructors pass instead of re-resolving capabilities afterwards; blob
transfers raise TransportError rather than httpx's own, so the extra
translation goes; a mailbox or Sieve script name the server's limits rule
out is refused as CapabilityFieldError before the call, which the
user-facing /set handlers now render like the per-object error it replaces;
and maxDelayedSend is read through SubmissionCapability, where 0 means the
server holds nothing rather than 30 days.
Email/import, CalendarEvent/parse and ContactCard/parse have builders in
2.0.0, so the raw batch.add calls go, and with them the Mail Queue's
raw-dict branch for the import answer.
Replaces the hand-rolled ECDH, HKDF and AES-GCM in push_subscription.py and
the key generation and validation in Mail Settings with jmaplib's RFC 8291
implementation, and adds a test that encrypts a push the way an application
server does and reads it back through the site's keys.
Suite Cloud adds a site's domain only once a TXT record at its apex proves
control, which the made-up example.test domains the live classes provision
never can - so against a real Suite Cloud every StalwartIntegrationTestCase
failed in setUpClass. With `mail_test_domain` in the site config the classes
use a domain the operator has added and verified for the site instead, with
their per-run unique account names, and leave it in place.
The cancel path replaced the whole keywords map, so a scheduled mail came
back to Drafts unread and unflagged; develop patched $draft alone. The Outbox
query also omits an absent filter and sort now rather than sending null, as
every other optional argument does.
Decoding the stored public key for the comparison raised binascii.Error,
which reached the user as a 500 where the old check answered with a
validation message.
@s-aga-r s-aga-r changed the title refactor(mail,calendar): migrate all JMAP calls to jmaplib refactor(mail,calendar): migrate all JMAP calls to jmaplib 2.0 Sep 30, 2026
…ests

Suite Cloud now has a setting that takes a development cloud's tenants at
their word for their domains, so the live classes provision their own
example.test domains again and mail_test_domain goes.
get_events read an empty id list as "every event", so a filter that matched
nothing had fetch_calendar_events answer with the first page of unrelated
events. Only None means all of them now.
The create and update paths render a method-level error as a message; the
delete paths for calendars, events, event notifications and participant
identities, and the event fetch, let it through as a bare 500. They now
answer the same way.
jmaplib 2.1.0 answers a calendar server's CalendarAlert push as a typed
object beside StateChange and PushVerification, so the endpoint hands the
body to read_push - decrypting with the site's PushKeyPair when the server
encrypted it - and dispatches on the object it gets back instead of on a
hand-read @type; anything that is no push object is refused where the
decryption already was. The alert job is handed the alert in wire form, as
before.
jmaplib 2.0's MailCapability carries maxSizeAttachmentsPerEmail and
maxMailboxDepth, and its check_attachment_size and check_mailbox_depth
compare against them. Mail Queue now refuses attachments the account's
email may not carry when the row is validated - before a single blob is
uploaded and the draft sent to fail - and a mailbox created or moved under a
parent is refused before the call when it would sit deeper than the account
allows, rendered like the per-object error the server would have answered.
The client mirrored the old one and never retried. jmaplib 2.0 re-sends a
batch only when the request provably did not reach the server - a
connection failure, a 429 or a 503 - or a literal ifInState guards it, so a
single retry cannot duplicate a write; a Retry-After past five seconds is
not waited out, so a web worker is never parked on an overloaded server.
A client and the account views made from it start from one session but
each kept its own copy, so a session state that changed mid-request was
noticed by every view in turn: each fetched the session again and resynced
the JMAP Accounts. They now share a peer list, and whichever notices the
change moves the others on with it. Covered on FakeJMAPServer, which the
glue had no test of its own against.
Every script save uploaded the content to the endpoint and then sent the
SieveScript/set naming the blob: two round trips. Where the server offers
RFC 9404, a Blob/upload queued in the same request now creates the blob,
and the /set names it by creation id - or, for validate's blobId argument,
by a result reference to the upload's answer. A server without blob
management still gets the script through the upload endpoint first.
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Replaces internal JMAP library with external jmaplib dependency.

No new blocking issue was identified; the PR appears safe to merge.

Reviews (17) · Last reviewed commit: "fix(mail): a sync left to another run do..."

…partial moves

- Mail Exchange refuses an import that files an email in more folders than
  maxMailboxesPerEmail before anything is staged. jmaplib refuses such an
  email while building its chunk of the move, by which time the chunks ahead
  of it are already in their destination folders.
- A move that fails midway says how many emails or contacts were already moved
  and stay in the account; the rollback only removes what is still staged.
- A contact the server refuses to move reports the server's reason, as emails
  already did.
- Removing the staging mailbox or address book, and rolling one back, read the
  answer: a notDestroyed or a method error is logged as a failure instead of
  as "removed".

The exchange test modules were stubs; they now cover the destination checks,
the move errors, the cleanup logging and parse_contact_blobs halving its batch
when the server refuses it.
A method error on EmailSubmission/get read as an empty list on develop, so the
Outbox endpoints answered "This scheduled email no longer exists."; with
jmaplib it raised and the request failed. A refused get reads as nothing found
again, a refused Identity/get costs a row only its address, and a refused
EmailSubmission/set or Email/set carries the server's reason the way a refused
object does.

Send At is checked with check_delayed_send, so a server that cannot hold a
message, or holds one for under a day, is no longer described as "0 days".

Left as they are: _query_page and _get_emails. Develop turned an error there
into an empty Outbox or a "message deleted" row, which hides a server failure
and, in the cancel path, would report a message as moved to Drafts when it
was not.
… wire

Against jmap.testing.FakeJMAPServer, asserting on the requests it received:

- jmap_events: query_around, occurrences_from, remove_overrides, the three
  patch shapes of set_instance_participation_status, get_events([]) and
  omit_none in the expanding query.
- suite.mail.jmap: translated_errors, a session revived from Redis,
  chunked_set across chunks, upload_blobs and download_blobs, and
  get_max_delayed_send for 0 and for a server that does not say.
…le path

UserSettings closed its httpx.Client only when connecting raised; it is now
closed on every way out except the one that hands the client back. The
frontend comment named suite.mail.jmap.connection, which is suite.mail.jmap.
Comment thread suite/calendar/doctype/calendar/calendar.py Outdated
s-aga-r added 10 commits October 2, 2026 19:10
fetch_calendars and fetch_participant_identities answered a refused get with
an empty list and then cached that list's length as the total, so the list
view's count read 0 - for up to ten minutes, or until the next listing that
succeeded. A refusal says nothing of how many there are: the cached total is
left as the last listing set it.
…e acted on

A failed write is safe to send again only if the failure proves it changed
nothing. never_applied() answers that in one place: true for a request jmaplib
refused before sending, a connection that was never made, a URL that cannot be
opened, rejected credentials, a 4xx problem (a 429 included) and a method
error other than serverPartialFail, malformedResult and missingResponse;
false for a timeout or a dropped connection after the bytes went out, and for
any 5xx - a gateway's 502, 503 or 504 can follow a request it forwarded.

jmaplib reports a failed API call as a bare TransportError: it raises it
outside the except block, so neither __cause__ nor __context__ holds the httpx
error. SuiteHTTPClient, the httpx.Client the glue now builds, remembers why
its last request failed, and translated_errors reads it from there. That also
fixes a URL scheme httpx cannot open being reported as an outage on an API
call: it was told apart only on the session, upload and download paths.

httpx.InvalidURL is not an httpx.HTTPError; jmaplib does not wrap it and
translated_errors does not translate it, so it reaches the caller as itself.
A test pins that on the API-call path.
A session refresh cached the new session and then synced the JMAP Accounts.
When the sync failed, the cache already held the new state, so no later
request found the session stale and the accounts stayed unsynced until the
session changed again. The session is now cached only after the sync: a
failed one leaves the old session in the cache, and the next request
refreshes and syncs again.
The request carrying the draft and the submission can fail as a whole - a
read timeout, a dropped connection, a gateway's 502/503/504 - after the server
applied it. Such a failure fell into the generic handler, which scheduled a
retry, and the worker sent the mail a second time.

Once that request is on its way, a failure schedules a retry only when it
proves the request changed nothing (never_applied: connection refused, a 429,
rejected credentials, a call jmaplib refused to send). Otherwise the row ends
as Failed with its retry count kept and no next_retry_after, and says the mail
may have been saved or sent. A failure before the request goes out - a blob
upload, a mailbox or identity lookup - is retried as before.

Also:

- A submission the server confirms is Submitted even when the draft's own
  answer could not be read: the maybe-applied override turned it into Failed,
  and left a retry scheduled against a sent mail.
- The failure path overwrites _response, so a Failed row no longer shows the
  submit error of an earlier attempt.
- The maybe-applied rule for method errors moved to suite.mail.jmap, next to
  never_applied, which the exchanges use too.

Kept on purpose: a connection that was never made is still retried. Marking
it Failed would leave every mail queued during a mail server restart waiting
for a person, though none of them can have been sent.
_query_page and _get_emails still read a refused call's result and failed the
request; and since ee026bab6 every refused EmailSubmission/get read as "This
scheduled email no longer exists.", a transient serverFail included.

- The listing shows what it can: a refused query is an empty page, and a refused
  get costs the rows only what it would have told about them.
- Details and actions answer "no longer exists" only for accountNotFound or
  notFound. Any other error throws the server's reason.
- Moving a canceled email back to Drafts never reads a refused lookup as
  nothing to move: the frontend reports any success there as "back in your
  drafts".

retry_failed_mail resubmitted and then raised when destroying the old record
was refused; the error invited a second retry, and a second send. After a
successful resubmit the destroy failure is logged and the call succeeds.
format_method_error returned str(error) for a call jmaplib refuses before
sending, which showed account ids, method names and capability URNs in text
that cannot be translated. A read-only account, an account the session does
not name and a method or capability the server lacks each get a sentence of
their own; jmaplib's text goes to the error log.

In the same doctypes:

- delete_mailboxes and update_mailbox_position drop the cached mailbox list
  in a finally: a refusal can follow mailboxes that were deleted or moved, and
  the throw skipped the invalidation.
- A vacation response the server refuses as an object (notUpdated) throws its
  reason instead of returning as saved.

Tests cover the three sentences and that rejected credentials and an outage
still propagate as themselves.
…d seeding backs off

- Event notifications: a query the server refuses returns no total, and the
  listing cached that as the count, which then read 0 until a listing
  succeeded. The cached total is left alone, as ef891c7 did for calendars
  and participant identities.
- Default alerts: a calendar that refuses the update left the seeded mark
  unset, so every sidebar load sent Calendar/set again and logged an error. A
  failed seeding now sets a one-hour back-off mark, apart from the 24-hour
  success mark; creating a calendar clears both, so a new calendar does not
  wait out another's refusal. The client is resolved before the best-effort
  block, so a caller the account does not belong to is refused there instead
  of setting the back-off on someone else's account.

Tests: the count after a refused notification query, the back-off, events
whose series lookup is refused coming back unchanged, and the foreign-account
asserts in test_calendar_calendars now check the "does not belong" message.
…ehind

Exchange imports move staged items into place in chunks, and the rollback
only removes what is still staged.

- Items refused one by one (notUpdated) were reported as "Failed to move N",
  with nothing said of the ones that did move. They stay in the account, and
  a re-run of the import duplicates them: the message and the exchange output
  now say how many moved and that they remain.
- When a chunk fails without the server saying it did nothing (no answer, a
  5xx, serverPartialFail - never_applied is false), that chunk may be applied
  too. The output says the move was not confirmed and gives the count as a
  floor, instead of "the rest were not imported".

Deleting push subscriptions: an outage on a later chunk lost what the earlier
chunks deleted. With something already deleted it now throws that outcome,
since the frontend replaces the message of a MailServerUnavailableError with
its generic one; with nothing deleted the outage is re-raised as it was.
A limit was rounded into the largest unit it reached: 30 seconds read as "1
minute" and 25 hours as "1 day". It is now given in the largest unit that
divides it, down to seconds.
assertRaises(Exception) passed for any failure at all; the script API throws
a ValidationError carrying the server's reason.
Comment thread suite/mail/api/scheduled.py Outdated
A retry resubmits the email and then removes the failed record. When the
removal fails, the record stays in the Outbox with its Send Again action, and
using it created another submission of the same email: a duplicate.

retry_failed_mail now asks the server what else it holds for the email. If a
submission was made after the record under retry, that record was already
retried: nothing is sent, the removal is tried again, and the later
submission's id is returned. The latest record of an email stays retryable,
whatever older ones are left behind.

"Made after" is read from the ENVID this app writes, a UUIDv7 that carries
its creation time; sendAt would not do between two of ours, a send-now
replacement being due before the held submission it replaced. sendAt decides
only when one of the two is another client's. A lookup the server refuses is
thrown: a retry sent on a guess is an email sent twice.

Not covered: two retries of the same record racing each other.
Comment thread suite/mail/api/scheduled.py Outdated
Comment thread suite/mail/api/scheduled.py Outdated
… once at a time

Two holes in 34f5847's guard against retrying a failed record twice:

- It took any later submission of the same email for the record's retry. A
  submission another client made after the failure - scheduled for later, say
  - then had Send Again remove the failed record without sending anything.
  The submission a retry creates now carries an ENVID derived from the failed
  record's own, and only a submission with that ENVID counts as its
  replacement. Ordering submissions by creation time or sendAt is gone.
- Two Send Again requests at once could both look, both find no replacement
  and both send. The lookup and the create now run under a Redis lock on the
  record; a request that cannot get it within ten seconds is told the email
  is already being sent again.

The tests run against a fake Outbox that keeps what it is sent, so a second
retry sees what the first one left.
httpx.ProxyError joins NEVER_SENT. httpcore raises it only when a CONNECT
tunnel is answered with a non-2xx or a SOCKS handshake fails, before the
request is written, so a mail whose send met one is retried instead of left
Failed for someone to resend by hand.

Won't fix: httpx.LocalProtocolError. Over HTTP/1.1 it is raised while the
request head is built, before anything is sent - but over HTTP/2 httpcore
raises it for a protocol error at any point of the exchange, and it would
then vouch for a request that may have been applied if HTTP/2 were ever
switched on. It also buys nothing: a request httpx cannot encode fails the
same way on every retry.
d74b66d cached a refreshed session only after the account sync succeeded,
so that a failed sync was tried again. While the sync kept failing, though,
every request fetched the session again, ran the sync again and wrote an
Error Log entry; and inside one long job nothing retried, the session being
no longer stale.

The session is now cached whether or not the sync succeeds - it is good, and
need not be fetched by every request - together with the time from which an
owed sync may be tried again. The clients of a job carry the same time, and
the first call after it has passed runs the sync; a new session state still
runs it at once.
…ing to confirm

An exception raised while working through the answer of the send request - a
KeyError on a server-set property the server left out - was recorded as the
mail server not confirming the mail. The row now knows whether the request
was answered:

- A submission the answer confirms is Submitted; the processing error is kept
  on the row and written to the Error Log.
- Short of that the row is Failed without a retry, and says the answer could
  not be processed rather than that none came.
- blobId, size and threadId of the created draft are read as optional.
maybe_applied() names the failures that leave open whether a request was
applied: the server, or the way to it, failing in a manner never_applied does
not vouch for. An error that is not about the server at all is neither.

- Exchange imports reported any unrecognised exception in the move as "the
  mail server did not confirm the move". That wording is now kept for
  maybe_applied errors. A failure of our own goes down the generic failure
  path with the count of what moved and no word on the server; the count
  itself is taken as before.
- Deleting push subscriptions said "some of the rest may have been deleted as
  well" after any outage. When the outage proves the rest were not touched (a
  connection never made, a rate limit) it says so, and that the server was
  unavailable, so the user knows to try again.
- The two sentences of a partly refused move are joined with <br>, which
  Frappe's message dialog keeps apart.
update_mailbox_position raised only when nothing at all was updated. A
reorder can renumber neighbours to make room, so the mailbox itself being
refused while a neighbour was renumbered passed for success. It now throws
the mailbox's own reason whenever the mailbox is not among the updated; a
refusal of neighbours only is logged. (Older than this branch.)

In the same code:

- The cache invalidation in the two `finally` blocks is wrapped: one that
  raised would replace the error being raised.
- The two throws this branch added no longer pass format_method_error's text
  through _() a second time.
- format_method_error notes the refusals a user can run into (read-only
  account, account or capability not offered) in the mail log file instead of
  writing an Error Log entry each time; an unexpected class still gets one.
  Log titles are no longer translated.

The unsupported-method test queued its call through a mocked execute. jmaplib
raises UnsupportedMethodError where a method is queued by name, which no mail
doctype does: through the typed namespaces a missing capability is an
AttributeError, and that is not handled here. The test now takes jmaplib's own
refusal and puts it through format_method_error.
The test named for the back-off being gone only covered
forget_default_alerts_seeded, which is what creating a calendar calls. It is
renamed for that, and a new one expires the back-off mark itself and expects
the next load to send Calendar/set again.
Comment thread suite/mail/doctype/mailbox/mailbox.py
Comment thread suite/mail/jmap.py
…s done

Placing a mailbox where there is no gap renumbers its neighbours. One that
refuses stays at its old sort order and can list on the wrong side of those
that took theirs, and 4dbd5cc only logged that. The listing the applied
updates give is now compared with the one asked for: a difference throws the
neighbour's reason, and a refusal that changes nothing in the order is still
only logged.
…un owes

sync_jmap_accounts returns without syncing when another run holds the user's
lock. A request that refreshed the session at the same time took that for a
sync that succeeded and cached the session with nothing owed - over the mark
of the other request, whose sync had failed - and the accounts stayed
unsynced until the session changed again.

sync_jmap_accounts now says whether it did the sync, and a request that left
it to another run caches nothing: the outcome is that run's to record.
@s-aga-r

s-aga-r commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai review

@s-aga-r
s-aga-r merged commit bd9cab8 into frappe:develop Oct 2, 2026
17 checks passed
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.

1 participant