refactor(mail,calendar): migrate all JMAP calls to jmaplib - #706
Merged
Merged
Conversation
- 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.
…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.
Contributor
|
…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.
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.
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.
… 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.
…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.
Collaborator
Author
|
@greptileai review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 namejmap). 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 (samejmap:sessionshash),MailServerUnavailableErrortranslation (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 inCalendarEventService, including develop'squery_around,occurrences_from,set_instance_participation_status,remove_overridesandset_overrides.Call sites use jmaplib's
client.batch()builders directly. Stalwart management is out of scope: develop moved it tosuite_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
CalendarEvent/parse,ContactCard/parseandEmail/importgo through jmaplib's typed builders; the exchange doctypes and event notifications were adjusted.Nonevs omitted. Stalwart rejects an explicitnulltop-level method argument withnotRequest; the old transport silently dropped them. Optional arguments go throughomit_none(...)./setand torn/get. jmaplib refuses a/setovermaxObjectsInSetinstead of splitting it, and raisesTornReadErrorwhen an auto-chunked/getsees a state change mid-read. Bulk paths usechunked_set/chunked_get(the latter keeps the old "concatenate chunks" semantics so a concurrent mailbox change can't abort a large export/import).CalendarEvent/querycarry one bare FilterCondition naming bothafterandbefore, 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, sojmap_eventssends those throughbatch.addwith develop's exact wire form.Calendar/getnames its properties. Stalwart's default set omitsisVisible, which read as every calendar hidden; every calendar read passesCALENDAR_PROPERTIES.CapabilityFieldError); the user-facing/sethandlers render it like the per-object error it replaces.maxDelayedSendis read throughSubmissionCapability:0now means the server holds nothing, not 30 days.PushKeyPair(RFC 8291, oneaes128gcmrecord) for key generation, validation and decryption, andread_pushfor the body: the endpoint dispatches on the typedStateChange/PushVerification/CalendarAlertit returns and refuses anything else. Keys generated from now on are stored unpadded, as RFC 8291 writes them; padded keys already stored keep working.Email/setorEmailSubmission/setmarks 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._responseis the structured create/notCreated payload;error_messagetolerates both the oldmethodResponsesshape (rows survive 3 days and are retryable) and the new one./setor/queryon the calendar delete and fetch paths renders throughformat_method_errorlike the create and update paths always did; develop's transport read the error body as an empty result and reported success.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 literalifInStateguards it, so a blip heals without a duplicate write; a longerRetry-Afteris not waited out.maxSizeAttachmentsPerEmailwhen the row is validated, before any blob is uploaded; a mailbox created or moved deeper thanmaxMailboxDepthis refused before the call.Blob/uploadqueued in the same request as theSieveScript/set(or/validate), one round trip fewer per save; without blob management it still goes to the upload endpoint first.get_eventsread[]as "every event", so a calendar query that matched nothing answered with unrelated events (pre-existing on develop).Verification
FakeJMAPServerand fake-Suite-Cloud modules pass ons2(bench --site s2 run-tests --app suite --module …), including develop'stest_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 newtest_push_encryption.StalwartIntegrationTestCasemodules (7 calendar, 14 mail; 176 tests across their classes) pass ons2against themail.c1.frappe.emailcluster through Suite Cloudsc1, provisioning their own*.example.testdomains. That needs Suite Cloud's new Skip Domain Verification setting (frappe/suite_cloud, branchskip-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 — seesuite/mail/tests/docker/README.md.CalendarAlerttoread_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
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 mustrm -rf suite/mail/jmap/(andsuite/mail/stalwart/, from before develop's move) and restart processes.jmap:sessionsRedis hash once at deploy — the cached session document shape changed from the pre-jmaplib format.git revert+ restart + the same flush.Follow-ups
None outstanding.
🤖 Generated with Claude Code