A native Android client for plMail, talking to it over JMAP (RFC 8620 / RFC 8621).
plMail is a self-hosted mail client, so there is no canonical server: the app asks for an address at onboarding and expects LAN hostnames, Tailscale nodes, private-CA certificates and a NAS that is sometimes rebooting. Everything the app does is shaped by that — cache aggressively, poll rarely, degrade per-account, and never busy-loop against someone's home server.
The server's docs/CLIENT_DEVELOPMENT.md
is the specification this app implements. Read it before changing anything here. Where it and
src/Jmap/ disagree, the code wins — the server moves faster than its documentation.
| Path | What |
|---|---|
app/ |
The application module: Application, the activity, navigation, DI graph. Deliberately thin. |
core/jmap/ |
Transport and codec. A plain JVM module — no Android, on purpose. |
build-logic/ |
Convention plugins. No module repeats an android {} block. |
gradle/libs.versions.toml |
Every dependency version, declared once. |
.github/workflows/ |
CI on every push, and the release build a v* tag triggers. |
core/jmap has no Android dependency by design: it is the code most worth testing — every wire
shape the server can hand back — and keeping the frameworks out means its suite runs on the host in
seconds rather than booting an emulator. The module boundary is what enforces it; an accidental
import android.* fails to compile.
./gradlew build./gradlew :core:jmap:testThe second is the tight loop: pure JVM, no emulator, seconds.
Formatting is ktfmt in kotlinlang style (4-space indent, per
the Android Kotlin style guide), driven by Spotless from the root build:
./gradlew spotlessApplyWarnings are compile errors. -Pplmail.warningsAsErrors=false turns that off for the one case that
justifies it — bisecting against a compiler that has started warning about something unrelated to
the change under test.
- JDK 21
- Android SDK with platform 37 and build-tools 36 (
ANDROID_HOMEset) - minSdk 31 · targetSdk 36 · compileSdk 37
targetSdk is 36 rather than 37 deliberately: SDK 37 is released, but no emulator system image
exists for it yet, and declaring a target whose behaviour changes have never been run is a guess.
Onboarding pairs by scanning a QR code from the server's Settings → App passwords, or by
tapping the plmail://pair?host=…&code=… link when the code is already on the device. The QR
carries a short-lived single-use pairing code, never the app password itself. Pasting an app
password by hand still works as a fallback.
For anything automated, use the server's isolated test stack so tests never touch real mail:
cd ../pl_mail && TEST_HTTP_PORT=8002 docker compose -p pl_mail_android -f compose.test.yaml up -d --build --wait appThat serves at http://127.0.0.1:8002, which is http://10.0.2.2:8002 from inside an emulator.
Seed it with app:test:seed-user, seed-mail, seed-label and seed-attachment, and mint a
credential with app:test:seed-api-token -q.
The session object's URLs are generated from the request's Host header, so the app must read
apiUrl, uploadUrl, downloadUrl and eventSourceUrl from it rather than deriving them. That is
what makes one credential work from both the emulator and a phone on the LAN.
Bump VERSION_NAME in build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt,
commit it, then tag the commit:
git tag v0.2.0 && git push origin main v0.2.0The tag is the trigger. CI runs the unit tests, builds and signs both flavours, and publishes a GitHub Release with the APKs, the app bundles and the R8 mapping files attached — and refuses to publish at all if the tag and the version in the build disagree.
docs/RELEASING.md has the details, including the four repository secrets the
signing step needs. Building a release locally needs none of them: without a signing identity
./gradlew assembleRelease simply produces an unsigned APK.