Skip to content

v0.4.0 - #51

Merged
karatektus merged 20 commits into
mainfrom
milestone/v0.4.0
Oct 25, 2026
Merged

karatektus merged 20 commits into
mainfrom
milestone/v0.4.0

Conversation

@pmdevelopment

@pmdevelopment pmdevelopment commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Collects the work for the v0.4.0 milestone on milestone/v0.4.0 and brings it to main together. The app release v0.4.0 ships alongside plMail v0.4.0 (karatektus/pl_mail#182). App versions jump from 0.0.31 straight to 0.4.0.

A draft until the milestone is complete. Each piece is reviewed as its own pull request into milestone/v0.4.0, and appears here once it is merged there.

Closes #2, closes #9, closes #16, closes #34, closes #41, closes #47, closes #52, closes #43, closes #58, closes #59, closes #60, closes #62. Their pull requests went into the milestone, so the issues close when this one merges.

Pull requests in the milestone

PR What Closes State
#17 Swipe between conversations in the reader #16 merged
#39 E2E phase 1: the person's keys onto the device (pairing with keys, password or passphrase, Keystore) #9 merged
#40 E2E phase 2: reading end-to-end encrypted mail (PGP/MIME and inline, signature inside, protected subject) #9 merged
#42 E2E phase 3: decrypted copies for accounts that keep them (DecryptedMail/*) #9 merged
#44 E2E phase 4: sending encrypted mail (ContactKey/lookup, MIME, Email/import, encrypted drafts, replies) #9 merged
#45 E2E phase 5: protecting the keys behind the screen lock (optional) #9 merged
#48 S/MIME part 1: the person's certificates and reading S/MIME mail #47 merged
#49 S/MIME part 2: sending S/MIME encrypted mail #47 merged
#53 Fix: the inbox no longer stays on "Fetching your mail…" after pairing (a missed Room invalidation) #41 merged
#54 Fix: list inbox tabs whose mail is only on the server (DB version 11) #52 merged
#55 Reader swipe: find the neighbours without recomposing on every Paging change #34 merged
#56 Fix: connect where the form says after a scanned code failed #2 merged
#61 Fix: take conversations the server destroyed off the device (Thread/changes) #59 merged
#63 Fix: keep the inbox at its top while new mail arrives #60 merged
#64 Fix: start live updates when a pairing is saved, not only on resume #43 merged
#65 Fix: open a conversation at its first unread message #58 merged
#66 Fix: fetch the messages of a conversation the device has never seen #62 open

The last merge of main into the milestone was on 2026-10-22. It brought:

Still open in the milestone

#2 (a corrected server address was ignored after a scan) is done by #56. The wrong address in the QR code was fixed on the server in plMail v0.3.2.

Depends on the server

End-to-end encryption needs plMail v0.4.0 (urn:plmail:params:jmap:keys). Against an older server the app shows no encryption and otherwise works as before.

Before merging

🤖 Generated with Claude Code

jo2904 and others added 10 commits October 20, 2026 16:26
* fix(reader): don't let a conversation left behind redraw the reader

ReaderViewModel.open() launches a load per conversation and never
cancels it. When conversation A's bodies finished downloading after B
had been opened, A's second show() replaced the reader's state with A:
the user was looking at B and the screen switched back to A. Reachable
today by tapping two notifications in a row; swiping between
conversations makes it routine.

The load still finishes: its bodies land in the cache, and cancelling it
would throw away a download the user may come straight back to, which
is the reason attachments run on the ViewModel scope too. It only stops
drawing when its conversation is no longer the open one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP7Zev5qvDRzKbcmecvRGW

* feat(mail): swipe between conversations in the reader

A horizontal swipe in the reader opens the conversation beside the open
one in the list it came from: left for the next (older) one, right for
the previous. The direction the content moves, as the calendar's
pageOnSwipe reads it.

Judged on release past 20% of the pane's width, the calendar's
threshold, rather than followed under the finger. Following would mean
composing the neighbour while the finger is down, and composing it
expands and marks its newest message read on a glance nobody committed
to. The new conversation slides a quarter-width in and fades from the
side it was pushed towards; one tapped in the list still appears in
place.

The order is the list on screen. MailPane now collects the
LazyPagingItems and hands them to MailScreen, so both read one copy of
what Paging has loaded. The neighbour and the open row are read through
threads[i], which is what keeps Paging loading ahead while the reader
covers the list; reading the snapshot alone, a run of swipes stopped at
the end of the first page.

The message body is a WebView and takes the gesture before Compose sees
it. Whether a wide table still has room cannot be asked: it scrolls
inside #plmail-message-root, not the page, so canScrollHorizontally
answers for a page that never moves sideways, and JavaScript is off.
What Chromium reports is the remainder: a scroll the wrapper cannot take
chains to the root scroller, and the unused delta comes back through
overScrollBy even with over-scroll effects off. ReaderWebView counts it,
so a table scrolls to its edge first and a swipe needs a further pull.
If a WebView version does not report it, the body keeps today's
behaviour and the rest of the reader still swipes.

The reader is keyed on the conversation, so one opened by a swipe starts
at its top, not at the previous one's scroll depth. TalkBack gets "Next
conversation" / "Previous conversation" as custom actions, offered only
where there is one; en and de strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP7Zev5qvDRzKbcmecvRGW

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The first phase of end-to-end encryption in the app (#9, the app's half
of karatektus/pl_mail#28): getting the keys here, two ways, and keeping
them safely.

- :core:crypto (new, pure JVM): the browser's wrapping (`v1.<iv>.<ct>`,
  AES-256-GCM, purpose as associated data), its PBKDF2/HKDF derivation of
  the wrap key, opening user keys, the pairing bundle, and unlocking an
  address key with PGPainless 2.0.4 through its token.
- :core:jmap: `#k=` from the pairing URI (never sent anywhere), `keys` in
  the redeem answer, the `urn:plmail:params:jmap:keys` capability and
  `keepsDecryptedMail`, and `Keyring/get`.
- :core:datastore: KeyVault, the user keys sealed under a Keystore key of
  their own (`plmail.e2e.keys`), so phase 5 can put authentication on it
  without locking the app password.
- :core:data: the bundle is opened while pairing and kept in memory until
  the connection is confirmed; EncryptionRepository reports where the
  device stands and opens the keyring with the plMail password or the
  encryption passphrase. A refused app password and signing out take the
  keys with them.
- Settings: an Encryption screen (status, pairing hint, password or
  passphrase, address keys with fingerprints, remove), reached from the
  drawer. English and German.

Tested against the server's KDF vector and fixtures written with Python's
cryptography and the server's openpgp.js 6.3.2, and end to end on an
Android 17 emulator with a minified release build against the test stack:
pairing with keys from a real browser keyring, removing them, a wrong
password, and unlocking with the right one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The second phase of end-to-end encryption in the app (#9): an encrypted
message is decrypted on the device when it is opened, the way the web app
reads it (assets/crypto/message.js).

- :core:crypto: EncryptedMessages finds the ciphertext (PGP/MIME or inline),
  decrypts it with every address key the device can open, reports the
  signature inside, and parses the decrypted entity with mime4j: HTML,
  text, parts, and the subject protected behind `...` (RFC 9788).
- :core:data: MessageDecryptor downloads the message's own blob (ciphertext,
  cached like any blob), judges the inner signature as the web app does
  (own key valid; pinned contact key valid, unpinned untrusted; another key
  key_changed; unknown key unknown_key; inner RFC 3156 unchecked), and
  sanitises the HTML with jsoup to the web app's DOMPurify rules, since no
  server has seen it. Decrypted content is held in memory only; a part
  opened in another app is written to the private cache and removed when
  the reader closes. Unlocked address keys are kept in memory for five
  minutes instead of being fetched and unlocked per message.
- :core:jmap: the Email crypto properties in every list fetch, and
  ContactKey/get.
- Database version 10: the five crypto facts on EmailEntity and
  ThreadEntity.isEncrypted (server facts only; a re-sync as every bump).
- Reader: a banner with the web app's wording (EN, and the web app's own
  German): encrypted, the inner signature, a signed-only message's server
  verdict, and why a message did not open, with a retry. The protected
  subject replaces the placeholder. cid: images come from memory.
- List: a lock on rows whose newest message is encrypted, also spoken.
- Notifications: "Encrypted message" and nothing else, and no reply from
  the shade, which could only answer in the clear.
- Release build: -dontwarn for slf4j's optional binding, which PGPainless
  now reaches; mime4j's duplicate META-INF/DEPENDENCIES excluded.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The third phase of end-to-end encryption in the app (#9). An account can
keep a readable copy of its encrypted mail on the server (switched on in
the web app), so search, rules, parcels and dates work on it. The server
has no keys to make that copy; a device that does hands it over, as the
browser's decrypted_mail_controller.js does.

- DecryptedMailKeeper asks DecryptedMail/pending for each such account,
  downloads each message's source, decrypts it, uploads the inner MIME
  message whole and hands it over with DecryptedMail/store. Inline PGP is
  wrapped exactly as the web app's inlineEntity wraps it. One run at a
  time, at most a hundred messages per run, and a round that stores
  nothing ends the run instead of asking for the same ids again.
- It runs after the periodic sync, after a push, and when the app comes
  to the foreground (LiveUpdates), the moment the web app does it too.
- :core:crypto: OpenedMessage.mime, the decrypted entity as the server is
  to receive it. :core:jmap: DecryptedMail/pending and /store.

Checked on an Android 17 emulator with a minified release build against
the pl_mail test stack: with keep-decrypted switched on for the account,
six pending messages (PGP/MIME and inline) were handed over within ten
seconds of the app coming to the foreground; the server then reports
decryptedOnServer, replaced "..." with the protected subject, found the
attachment, and finds the mail by search; the reader shows it from the
server's copy with "Encrypted on arrival · stored readable on this
server".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The fourth phase of end-to-end encryption in the app (#9). The server
cannot build an encrypted message, so the app writes the whole message,
encrypts it and imports it as a draft, which is then sent as before.

- :core:crypto: OutgoingMessages builds the inner MIME entity as the web
  app's mime.js does (CRLF, base64 leaves, text and HTML, files, the
  protected Subject/From/To/Cc of RFC 9788), signs and encrypts it with
  PGPainless to every recipient and to the sender's own key (key IDs
  hidden when there is a Bcc), and wraps it in PGP/MIME with "..." as
  the outer subject.
- :core:jmap: ContactKey/lookup, Email/import with plMail's
  fingerprints, and Email.sealedDraft.
- :core:data: OutgoingEncryption asks the server about the recipients'
  keys and decides by the web app's rules (auto, on, off; partial
  encryption said out loud, blocked when a recipient asks for "always").
  ComposeRepository saves an encrypted draft by upload and Email/import
  with $draft, and replaces it (old one to Trash) on every save, since
  the server cannot patch what it cannot read. It reopens such drafts
  decrypted, and decrypts the original of a reply or forward.
- Composer: a lock in the app bar, a hint under the recipients in the
  web app's words, a lock on each recipient chip that has a key. An
  answer to encrypted mail is encrypted and quotes the decrypted
  original under its protected subject; a forward carries the decrypted
  files. Encrypted drafts are saved when the composer closes or sends,
  not while typing, and nothing is ever saved in the clear while the
  recipients' keys are still being asked about. A draft sealed by the
  web app, or one whose keys are not here, opens read-only with a
  reason.
- Also fixes the composer opening on the previous message: on a phone
  its ViewModel belongs to the activity, so a second message in the
  same session (a reply included) showed the first one's content. It
  now resets once a message is saved, discarded or handed over.

Not yet: signing without encrypting, inline PGP, S/MIME.

Checked on an Android 17 emulator with a minified release build against
the pl_mail test stack: a message to a contact with a key was imported
as an encrypted draft (seal kind encrypted, both fingerprints), went out
as multipart/encrypted with "Subject: ..." and no plaintext, and the
web app decrypted it with "signed with your own key" and the protected
subject. A reopened encrypted draft came back decrypted with its inner
subject. A reply to an encrypted PGP/MIME message opened encrypted,
with "Re: <protected subject>" and the decrypted quote, and went out
encrypted. Mail to an address without a key is saved and sent in the
clear as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The fifth phase of end-to-end encryption in the app (#9): an optional
setting, off by default, that asks for a fingerprint, face or the screen
lock before encrypted mail opens, as the issue describes.

- :core:datastore: KeystoreSecretCipher can gate its key on the person
  (setUserAuthenticationParameters, five minutes, strong biometric or
  device credential; unlocking the phone counts) and throws
  DeviceLockRequired instead of answering "unreadable". KeyVault keeps
  the keys under a second key, plmail.e2e.keys.protected, when the
  setting is on, re-seals them both ways on switching, and falls back to
  unprotected (and says so) when new keys arrive while it cannot seal.
- :core:data: DeviceUnlock asks through whichever activity is on screen,
  once for any number of callers, and answers no when none is. The
  EncryptionRepository asks only for interactive callers: opening an
  encrypted message, answering one, sending encrypted. The reader shows
  "your keys are locked" with Unlock when declined; the composer stays
  open with the reason; the settings screen reports locked keys without
  asking; background decryption pauses until the app is used again.
  Typing recipients asks nothing: the composer takes the signing
  fingerprints from the keyring while the keys are locked.
- app: the platform BiometricPrompt (API 30+, no new dependency),
  attached to MainActivity from onStart to onStop, and USE_BIOMETRIC.
- Settings → Encryption: the switch, offered only with a screen lock.

Checked on an Android 17 emulator with a PIN, minified release build
against the pl_mail test stack: switching on asks for the PIN; five
minutes later the list and a sync ask nothing, opening an encrypted
message asks once, declining shows the locked banner and Unlock opens
it, a second encrypted message then opens without asking, settings show
"Locked until you confirm it is you" without asking, writing to a
contact with a key asks nothing until Send, Send asks and the message
goes out encrypted, a declined prompt at Send keeps the composer open
and sends nothing, closing it declined saves nothing, and switching off
asks again and leaves the keys open without it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#48)

S/MIME in the app, reading half (#47). The person's certificates come
from Keyring/get, opened with the user keys already on the device;
S/MIME encrypted mail is decrypted here and its signature inside judged
here, as the web app does in the browser.

- :core:crypto: SmimeKeys opens a smimeCertificates entry (PKCS#8 under
  the user key, purpose plmail/smime-key). Smime decrypts CMS
  EnvelopedData and AuthEnvelopedData with Bouncy Castle's lightweight
  API (never a JCA provider, which on Android collides with the
  platform's own "BC"), including RSA-OAEP key transport, which the
  lightweight unwrapper reads as PKCS#1 v1.5 and fails. Ciphers: AES-CBC
  and AES-GCM, and the 3DES old Outlook sends. It checks a detached
  multipart/signed or an opaque SignedData inside, and returns the
  signed content without its wrapper (what DecryptedMail/store is
  handed, as from the web app). EncryptedMessages.open takes S/MIME keys
  and also opens opaque signed mail, which the server keeps no body for.
- :core:data: EncryptionRepository opens and caches the certificates
  with the address keys (protection from phase 5 applies), reports them
  to the settings screen, and asks ContactKey/lookup for a sender's
  certificate. MessageDecryptor judges an S/MIME signature locally (own
  certificate valid; the sender's known certificate valid if verified,
  else untrusted; another one changed; one that does not name the
  sender, or broken, invalid), since the server's judging route is
  web-session only. The background keeper hands S/MIME over too.
- Reader: "Encrypted with S/MIME", the web app's unreadable and
  unsupported hints, and opaque signed mail opened on the device.
- Settings → Encryption: the person's S/MIME certificates, with issuer,
  expiry and state.
- bcpkix 1.84 (the release PGPainless already brings), and a root
  lint.xml that ignores lint's TrustAllX509TrustManager in that one jar
  (EST code the app never reaches; R8 strips it).

Checked on an Android 17 emulator, minified release build, against the
pl_mail test stack: a .p12 imported in the web app shows under
Settings → Encryption; a message signed by a contact and encrypted to
the person (OpenSSL, AES-256) opens with its protected subject, HTML and
attachment, "Encrypted with S/MIME" and an intact signature judged
untrusted (the contact's certificate known from their signed mail, its
CA not trusted); an opaquely signed message opens with its content and
"intact, but nothing confirms who signed it".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
S/MIME in the app, sending half (#47). When OpenPGP cannot reach every
recipient but S/MIME can, the composer encrypts with S/MIME, as the web
app does: every recipient has a certificate, the sender has one for the
From address, and nobody is in Bcc.

- :core:crypto: OutgoingMessages.sealSmime builds the same inner entity
  as for OpenPGP (protected headers inside, "..." outside), signs it as
  multipart/signed (SHA-256 with RSA, signer by issuer and serial, its
  certificate and chain carried, smimeCapabilities AES-256/192/128) and
  envelopes that with AES-256-CBC and RSA PKCS#1 v1.5 key transport to
  every recipient and the sender: smime.js's smimeEncryptEntity, which
  Outlook and Thunderbird read.
- :core:data: the plan prefers OpenPGP, falls back to S/MIME, names the
  shorter list of the missing when neither works, and reports the
  scheme; RecipientKeys knows whether the From address has a
  certificate here (without opening protected keys). OutgoingEncryption
  seals with the certificate the server names for the sender and
  imports with every certificate fingerprint and that signer, as
  SealGuard checks.
- Composer: a lock on the chip of a recipient with only a certificate
  ("plMail has an S/MIME certificate for this address…"), and the
  scheme carried on the draft it hands over.

Checked with OpenSSL, standing for every other mail program: it opens
what the app builds with the recipient's key and verifies the signature
against the CA. On an Android 17 emulator, minified release build,
against the pl_mail test stack: a new message to a contact with only a
certificate shows the certificate lock and "will be end-to-end
encrypted", is imported as kind encrypted, scheme smime, with two
fingerprints and the person's certificate as signer, and goes out with
no plaintext; OpenSSL decrypts it as the contact and verifies it. A reply
to S/MIME encrypted mail opens with "Re: <protected subject>" and the
decrypted quote and goes out the same way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	app/proguard-rules.pro
@pmdevelopment pmdevelopment added this to the v0.4.0 milestone Oct 22, 2026
@pmdevelopment pmdevelopment added enhancement New feature or a change to how something works area: mail Concerns mail area: compose Concerns compose area: settings Concerns settings labels Oct 22, 2026
@github-actions github-actions Bot added area: onboarding Concerns onboarding area: push Concerns push area: jmap Concerns jmap area: build Concerns build labels Oct 22, 2026
pmdevelopment and others added 9 commits October 23, 2026 11:07
…airing (#53)

Right after pairing, the list could stay on "Fetching your mail…" with
every row already on disk, until the app was restarted (#41).

The cause is a missed Room invalidation. On a first launch the list's
PagingSource is invalidated by the account and mailbox writes and loads
again; when that second query ran just before the mediator committed its
first page, and the source registered its observer just after the
commit, nothing told it about the rows. Paging then held an empty page
over a full table. The mediator's row count (onRowsHeld) said rows
exist, so the screen chose "still fetching" over "empty", and nothing
retried.

The mediator now invalidates the list's PagingSource itself after every
committed write, through an InvalidatingPagingSourceFactory, for the
mailbox lists and for search. It costs one local query per page, which
Room would have run anyway.

Found on an Android 17 emulator with a minified release build against
the pl_mail test stack: with the emulator's CPUs loaded, one pairing in
three or four hung; the app's database then held all four feed entries
with their threads and an exhausted cursor, and the paging log showed
the second source load returning empty just before the mediator's
commit, with no load after it. With this change, ten pairings under the
same load all showed the inbox within a second.

Closes #41

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar lists a tab while it holds mail, as the web does, but asked
only the cache, and the cache holds a tab's conversations once the tab
was opened or new mail arrived in it. A tab a filter on the web filled
with existing mail, or one that rarely gets new mail, was therefore
never listed, and so could never be opened (#52).

After the tabs are read, the app now asks the server whether each
enabled tab holds inbox mail: one Email/query per tab with the
conditions its list pages with (in the Inbox, threadInboxTab), limit 1,
collapsed by thread. The answer is kept on the tab
(InboxTabEntity.hasMail) across re-reads, and the sidebar's "populated"
is the union of the cache and that answer. On a push or a tab refresh
where nothing changed, the questions ride along with InboxTab/changes,
so that stays one request; the directory refresh and a refresh that
re-read the tabs ask in a request of their own.

Database version 11 for the new column (a drop and re-sync, as for every
bump).

Checked on an Android 17 emulator with a minified release build against
the pl_mail test stack: a custom tab "Paket" with one existing
conversation filed into it on the server, and nothing of it on the
device, is listed in the sidebar right after pairing and opens with that
conversation.

Closes #52

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…led (#56)

A scanned or tapped pairing code was kept for the whole screen, and
connect() preferred it to anything typed afterwards. When the code's
address did not answer, correcting the address and pasting an app
password still sent "Connect" to the scanned address.

Editing the address or the app password now drops the invitation, so
the form signs in with what it shows. Scanning again brings a fresh one.

Closes #2

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… page (#55)

The reader read Paging's snapshot in its own composition, so every page
loaded, every sync and every row that changed its unread state recomposed
it, walking the whole loaded list and building an ActionTarget per row to
find the open conversation three times over.

The open row and the rows beside it are now one derived value: the
snapshot is still walked when it changes, without allocating, but the
reader only recomposes when one of the three indices moves.

Closes #34

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Delta sync only asked Email/changes. When the server erased mail or
merged conversations it reported the messages, never the conversation,
so the thread and its list entries stayed for good: an erased
conversation lingered, a merged one was listed twice.

Thread/changes now travels in the same request, from a cursor a page
records the way it records the Email one (set once, then moved by the
sync). Its destroyed ids delete the thread and its entries in every
list. A server that can no longer answer from either cursor discards
both and re-pages.

Closes #59

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A keyed LazyColumn keeps its first visible row in place when rows are
inserted before it. At the top of an inbox that put arriving mail above
the viewport, out of sight until the user happened to scroll up.

The list now remembers whether it is at its top. When its position
moves without a scroll, which only an insertion does, and it was at the
top, it is put back there with requestScrollToItem before the next
layout. Further down, and after a rotation restores a position, the
anchoring stays as it was.

Closes #60

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…sume (#64)

Live updates were started when the app came to the foreground. On a
first launch that is the onboarding screen: nothing was stored, the
catch-up found no accounts, the stream found no connection, and the job
ended. A phone paired a moment later received nothing live until it had
been in the background and back.

LiveUpdates now follows the stored connection while the app is visible:
the catch-up and the stream start the moment a connection is saved,
restart for a different one, and stop when it is removed.

Closes #43

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The reader expanded only the newest message. Unread messages before it
stayed folded away above it, were easy to skip, and were never marked
read, since a message is marked once it is shown expanded.

Every unread message now starts expanded together with the newest, as
the web app opens a thread, and the reader scrolls once to the first of
them. The start is decided by the first rebuild that has messages and
kept after that, so marking messages read while reading does not move
it. What the user expanded or folded still survives the rebuild once
bodies arrive.

Closes #58

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…r seen (#66)

A list page is collapsed to one message per conversation, and opening a
conversation only downloaded bodies for the messages already cached.
A thread paged in from the list therefore opened as its newest message
alone, under the last reply's subject, and nothing brought the others.

Opening a conversation now asks Thread/get what it holds and fetches,
in the one Email/get it already sent for missing bodies, the members
the device lacks. The thread row is stored as the server describes it,
so the list's subject and count are right afterwards too. A thread the
device holds whole costs the Thread/get and nothing more.

Closes #62

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@pmdevelopment pmdevelopment mentioned this pull request Oct 24, 2026
The app's numbering joins the server's from this release on
(CONTRIBUTING.md): versionName 0.4.0, versionCode 400, up from 31, so
it installs over v0.0.31. The bug template's version example follows.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@karatektus
karatektus marked this pull request as ready for review October 24, 2026 16:28
@karatektus
karatektus merged commit 82fd36c into main Oct 25, 2026
6 checks passed
@karatektus
karatektus deleted the milestone/v0.4.0 branch October 25, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment