Host your own email and enhance your privacy
The React admin app (react/admin/) and native Apple clients (apple/) currently serve as the Cabalmail clients. Version 1.1.0 introduces a native Android client that mirrors the user-facing portions of the Apple client (mail reading/compose/send, folder management, address creation and revocation, on-the-fly From addresses) without sharing code with either existing client.
Administrative functionality (user management, DMARC reports, multi-user address assignment) is out of scope. Admins will continue to use the web app for those workflows.
The Apple client has evolved substantially since this plan was first drafted (it was written against the 0.1.x-era feature set). Functional parity now includes several capabilities that shipped in the intervening releases and are reflected throughout the phases below:
/search_envelopes) as a first-class scope, not a query parameter — with a filter sheet and All / Unread / Flagged pills.message_id / in_reply_to / references), consumed for correct reply threading (the list itself stays flat — no conversation grouping in either client)./save_draft (the plan’s original “local-only drafts” is superseded)./get_nav_state / /set_nav_state — remember and offer to restore the last folder/message./get_preferences / /set_preferences — the From display name plus theme / accent / density, shared with the web app./set_favorite) and permanent delete / empty trash (/purge_messages, /empty_trash).Scope of “Android client” for 1.1.0:
WindowSizeClass)Wear OS, Android TV, and Android Auto are explicitly out of scope.
Play Store public release is explicitly not a 1.1.x goal – the roadmap places that at 1.5.x. This phase produces a working client that is continuously built and tested in CI and distributable via Play Console internal testing tracks.
Seven phases: project scaffolding and shared module; CI/CD (early, so every subsequent phase runs through it); authentication and API transport; mail reading; mail composition including on-the-fly From; address and folder management; platform polish and adaptive layouts.
ApiBackedImapClient – the hand-rolled IMAP stack proved unreliable across network transitions, sleep/wake, and provider quirks (Issue #371). The Android client skips that detour entirely and speaks only to the existing Lambda API surface (/list_folders, /list_envelopes, /fetch_message, /set_flag, /move_messages, /send, etc.). No IMAP library, no MIME transport layer, no IDLE plumbing.NavigationBar, NavigationRail, ListItem, SearchBar, SwipeToDismissBox, DropdownMenu, etc. The goal is an app that feels at home next to Gmail, not a translation of the iOS UI.WebView for HTML email rendering).| iOS plan | What actually shipped | Android implication |
|---|---|---|
| Direct IMAP/SMTP as primary transport | ApiBackedImapClient via Lambda API |
Start API-backed; no IMAP spike |
| MailCore2 vs swift-nio-imap spike | Neither – API-backed | No library evaluation needed |
| IDLE for foreground push | Polling (no IDLE over API) | Poll-based refresh from the start |
| Amplify Swift for Cognito | Amplify Swift | Use Amplify Android |
APPEND for sent/drafts |
/send handles Outbox + Sent; /save_draft owns the Drafts lifecycle |
Client-side APPEND still unneeded, but drafts sync server-side (see Phase 5) |
MIME parsing for fetchPart |
Fetch full body, parse MIME client-side | Same approach in Kotlin |
| Local-only drafts | Cross-device sync via /save_draft, Markdown-canonical bodies |
Same – Markdown-canonical compose, server draft sync |
| Flat list, no threading data | Envelopes carry message_id/in_reply_to/references; used for reply threading only |
Same – flat list, threading powers replies |
| Search as an afterthought | Cross-folder structured search (/search_envelopes) + filter sheet + All/Unread/Flagged pills |
Build search as a first-class scope |
| No cross-device resume | Nav-state cursor (/get_nav_state//set_nav_state) with restore prompt |
Same – resume cursor |
| Prefs stored locally | Display name + theme/accent/density synced via /get_preferences//set_preferences |
Sync the shared prefs server-side (see Phase 6) |
Erratum (2026-08-07): The “Amplify Swift” row is wrong: Amplify did not ship on Apple either. The Apple client’s auth is a hand-rolled Cognito
USER_PASSWORD_AUTHJSON client (CabalmailKit/Auth/AuthService.swift); Amplify Android should be weighed on its own merits, not as a mirror of the Apple choice.
| Choice | Decision | Rationale |
|---|---|---|
| Language | Kotlin only | Standard; no Java in new code |
| UI | Jetpack Compose + Material 3 | SwiftUI analog; Google-recommended |
| Min SDK | API 31 (Android 12) | Future-proofing over reach; cleaner Compose ergonomics, built-in Splash Screen API, Material You dynamic color without compat shims |
| Target SDK | Latest stable (API 35 / Android 15) | Play Store requires recent target SDK |
| Build | Gradle + Kotlin DSL with version catalog (libs.versions.toml) |
Current convention |
| Architecture | ViewModels + StateFlow + Repository (Compose-friendly MVVM) | Idiomatic; testable |
| HTTP | Ktor client | Pure Kotlin, multiplatform-ready if KMP ever materializes |
| Auth | AWS Amplify Android (amplify-auth-cognito) |
Mirrors Apple; same SRP flow; proven against the existing Cognito pool |
| Persistence | DataStore (preferences) + Room (envelope/body cache, if needed) | Modern Jetpack stack |
| Image loading | Coil | Compose-native, Kotlin-first |
| HTML rendering | WebView with hardened settings | Same model as iOS WKWebView |
| DI | Manual constructor injection to start | Don’t over-architect early; reach for Hilt only if wiring becomes painful |
| Testing | JUnit5 + Turbine (Flow testing) + Compose UI tests | Standard |
| Linting | ktlint + Android Lint | Mirrors swiftlint role from apple.yml |
A new top-level directory, sibling to apple/ and react/admin:
android/
settings.gradle.kts
build.gradle.kts # root build file
gradle.properties
gradle/
libs.versions.toml # version catalog
wrapper/
app/ # phone + tablet app module
build.gradle.kts
src/
main/
kotlin/com/cabalmail/android/
CabalmailApp.kt # Application class (Amplify init)
MainActivity.kt
ui/
mail/ # folder list, message list, detail
compose/ # email composition
addresses/ # address management
folders/ # folder management
settings/ # preferences
auth/ # login, signup, forgot password
theme/ # Material 3 theme, dynamic color
navigation/ # NavHost, route definitions
res/
AndroidManifest.xml
test/ # unit tests
androidTest/ # instrumented/UI tests
kit/ # shared library module
build.gradle.kts
src/
main/kotlin/com/cabalmail/kit/
auth/ # Amplify Cognito wrapper
api/ # ApiClient (Ktor), endpoint definitions
models/ # Envelope, Message, Address, Folder, etc.
cache/ # Envelope + body disk cache
mime/ # Client-side MIME parsing
config/ # Runtime config fetch + cache
test/kotlin/ # unit tests
README.md
kit/ is the spiritual sibling of CabalmailKit/. The split lets future targets (Wear, benchmark module) consume it without dragging UI dependencies.
Create android/ containing:
build.gradle.kts applying the Android Gradle Plugin and Kotlin plugin at the top level (no allprojects anti-pattern – use convention plugins or subprojects minimally).settings.gradle.kts including app and kit modules, with pluginManagement and dependencyResolutionManagement blocks.gradle/libs.versions.toml version catalog declaring all dependencies (Compose BOM, Ktor, Amplify, Coil, Room, DataStore, JUnit5, Turbine, ktlint).app/ module: com.android.application, min SDK 31, target SDK 35, Compose enabled, Material 3 theme with dynamic color.kit/ module: com.android.library, same SDK constraints, no Compose dependency (pure Kotlin + Android framework).The Apple client fetches https://{control_domain}/config.json at first launch (added in the iOS work as a JSON sibling to the React app’s config.js). The Android client uses the same endpoint.
kit/src/main/kotlin/com/cabalmail/kit/config/ConfigService.kt:
config.json on first launch via Ktor.DataStore (encrypted via EncryptedSharedPreferences if the config contains anything sensitive; plain DataStore otherwise since the config values are also served publicly).apiUrl, host, cognitoUserPoolId, cognitoClientId, mailDomains as a StateFlow<Config?>.The control domain itself is the one value that must be baked in at build time. Store it in app/build.gradle.kts as a buildConfigField:
Erratum (2026-08-18): Retired. Baking the domain in meant one build per environment and left the Play Console upload — which sets no property — pointed at the placeholder, unable to sign in anywhere. The client now asks for the control domain on the sign-in screen and remembers it per install (with the cached
config.json), the same as the Apple client;BuildConfig.CONTROL_DOMAINsurvives only as an optional developer prefill (empty by default) and no build-type or flavor variants exist.
buildConfigField("String", "CONTROL_DOMAIN", "\"admin.example.com\"")
Different values per build type (debug/release) or product flavor (dev/stage/prod) if needed.
kit/ module – scaffoldingauth/, api/, models/, cache/, mime/, config/.CabalmailClient class that will own the auth session and expose the API surface to the app layer.MainActivity.kt with a Compose setContent block.dynamicColorScheme() (API 31 guarantees this works).cd android && ./gradlew assembleDebug succeeds.cd android && ./gradlew :kit:test succeeds.Land the Android workflow against the Phase 1 scaffold so every subsequent phase develops under green CI. Unlike apple.yml which requires macOS runners, Android CI runs on ubuntu-latest – faster, cheaper (free for public repos), and no macOS minute multiplier.
.github/workflows/android.yml – triggers on android/** path changes, pushes to main/stage, and manual workflow_dispatch. Three jobs:
| Job | Runner | Purpose |
|---|---|---|
test |
ubuntu-latest |
./gradlew :kit:test :app:testDebugUnitTest, ktlint, Android Lint |
build |
ubuntu-latest |
./gradlew assembleRelease (unsigned – verifies compilation) |
upload |
ubuntu-latest |
Sign APK/AAB, upload to Play Console internal track (runs on main/stage only, skipped on PRs) |
Environment mapping follows the existing repo convention: main -> prod, stage -> stage. Other branches build and test only.
actions/setup-java@v4 with distribution: temurin and an explicit java-version (e.g. 21).ubuntu-latest runner has ANDROID_HOME set; Gradle auto-fetches missing SDK components via sdkmanager).actions/cache@v5 for ~/.gradle/caches and ~/.gradle/wrapper, keyed on hashes of gradle/libs.versions.toml, gradle/wrapper/gradle-wrapper.properties, and *.gradle.kts files.ktlint-gradle plugin, run in the test job. Mirrors swiftlint from apple.yml../gradlew lint. Warnings promoted to errors for release builds (lintOptions { warningsAsErrors = true }).Android signing is simpler than Apple signing – no provisioning profiles, no certificate import ceremony.
.jks locally (keytool -genkeypair), base64-encode, store as ANDROID_KEYSTORE_BASE64 secret. At job start, decode to a temp file.ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD).Signing block in the workflow:
- name: Decode keystore
run: echo "$" | base64 -d > "$RUNNER_TEMP/upload.jks"
- name: Build signed AAB
working-directory: android
env:
KEYSTORE_PATH: $/upload.jks
KEYSTORE_PASSWORD: $
KEY_ALIAS: $
KEY_PASSWORD: $
run: ./gradlew bundleRelease
With matching signingConfigs in app/build.gradle.kts reading from environment variables.
gradle-play-publisher (Triple-T) Gradle plugin: ./gradlew publishBundle --track internal. Requires a Google Play service account JSON key stored as PLAY_SERVICE_ACCOUNT_JSON secret.CHANGELOG.md (same sed pattern as apple.yml).github.run_number (monotonically increasing integer, which is all Play Console requires).name: Build and Deploy Android Client
permissions:
contents: read
on:
workflow_dispatch:
push:
branches: [main, stage]
paths:
- 'android/**'
- '.github/workflows/android.yml'
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }
- uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-$-$
restore-keys: gradle-$-
- name: Run tests and lint
working-directory: android
run: ./gradlew :kit:test :app:testDebugUnitTest ktlintCheck lint
build:
name: Build release
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }
- uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-$-$
restore-keys: gradle-$-
- name: Assemble release (unsigned)
working-directory: android
run: ./gradlew assembleRelease
upload:
name: Upload to Play Console
needs: [test, build]
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'stage')
runs-on: ubuntu-latest
environment: $
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }
- name: Decode keystore
run: echo "$" | base64 -d > "$RUNNER_TEMP/upload.jks"
- name: Publish to internal track
working-directory: android
env:
KEYSTORE_PATH: $/upload.jks
KEYSTORE_PASSWORD: $
KEY_ALIAS: $
KEY_PASSWORD: $
PLAY_SERVICE_ACCOUNT_JSON: $
run: ./gradlew bundleRelease publishBundle --track internal
android/**; confirm test and build run and pass against the Phase 1 scaffold.react/**, apple/**, or terraform/** change.stage; confirm a signed AAB uploads to the Play Console internal testing track.A single transport layer – the Lambda API surface – unified under CabalmailClient in kit/.
The Apple client uses Amplify Swift. The Android analog is Amplify Android (aws-amplify/amplify-android), which wraps the same SRP flow and handles token refresh.
Erratum (2026-08-07): The Apple client does not use Amplify (or SRP); it hand-rolls Cognito
USER_PASSWORD_AUTHinAuthService.swift. “Mirrors Apple” would mean a small hand-rolled JSON client, as the Linux plan later specified.
kit/src/main/kotlin/com/cabalmail/kit/auth/AuthService.kt:
signIn(username, password), signUp(username, password, email, phone), confirmSignUp(username, code)forgotPassword(username) / confirmForgotPassword(username, code, newPassword)signOut()suspend fun currentIdToken(): String – fresh JWT for API calls; refreshes if within 5 minutes of expiryEncryptedSharedPreferences (Android Keystore-backed) automaticallyAmplify initialization happens in CabalmailApp.kt (Application.onCreate), configured programmatically from the config.json values (no amplifyconfiguration.json file – the config is fetched at runtime).
kit/src/main/kotlin/com/cabalmail/kit/api/ApiClient.kt – a class wrapping Ktor HttpClient.
All requests attach Authorization: <idToken> via a Ktor HttpRequestInterceptor that calls authService.currentIdToken(). 401 responses trigger a single retry after a forced token refresh; a second 401 surfaces as AuthError.SessionExpired.
Endpoints (mirroring the Apple ApiBackedImapClient + ApiClient):
| Method | HTTP | Endpoint | Notes |
|---|---|---|---|
listFolders() |
GET | /list_folders |
Returns folder tree |
folderStatus(folder) |
GET | /folder_status |
STATUS: messages/unseen/uidvalidity/uidnext (+ optional flagged count) |
listEnvelopes(folder, sort, offset, limit) |
GET | /list_envelopes |
Positional (offset/limit) envelope window, sorted; carries threading + auth-result fields |
searchEnvelopes(query, filters, cursor) |
GET | /search_envelopes |
Structured search (single- or cross-folder), newest-first, opaque page cursor |
fetchMessage(folder, uid) |
GET | /fetch_message |
Full RFC 822 body |
listAttachments(folder, uid) |
GET | /list_attachments |
Attachment metadata |
fetchAttachment(folder, uid, part) |
GET | /fetch_attachment |
Returns presigned S3 URL |
fetchInlineImage(folder, uid, part) |
GET | /fetch_inline_image |
Inline image data |
setFlag(folder, uids, flag, value) |
POST | /set_flag |
Set/clear one IMAP flag (\Seen, \Flagged, \Answered, \Forwarded) |
moveMessages(folder, uids, dest) |
POST | /move_messages |
Move between folders |
purgeMessages(folder, uids) |
POST | /purge_messages |
Permanent expunge – trash folders only |
emptyTrash(folder) |
POST | /empty_trash |
Expunge an entire trash folder |
send(message) |
POST | /send |
Send; handles Outbox + Sent server-side; optional discard_draft_* |
saveDraft(message, op, replacesUid, replacesUidValidity) |
POST | /save_draft |
Save/replace/discard a Drafts copy (UIDPLUS lifecycle) |
listAddresses() |
GET | /list |
User’s addresses (includes favorite flag) |
newAddress(subdomain, local, comment) |
POST | /new |
Create address |
revokeAddress(address) |
DELETE | /revoke |
Delete address |
setFavorite(address, favorite) |
POST | /set_favorite |
Favorite/unfavorite an address (drives From-picker ordering) |
fetchBimi(domain) |
GET | /fetch_bimi |
BIMI logo lookup |
getPreferences() / setPreferences(...) |
GET/PUT | /get_preferences /set_preferences |
Server-synced prefs: name, theme, accent, density |
getNavState() / setNavState(cursor) |
GET/POST | /get_nav_state /set_nav_state |
Cross-device resume cursor |
newFolder(name, parent) |
POST | /new_folder |
Create folder |
deleteFolder(name) |
DELETE | /delete_folder |
Delete folder (must be empty) |
subscribeFolder(name) |
POST | /subscribe_folder |
Subscribe |
unsubscribeFolder(name) |
POST | /unsubscribe_folder |
Unsubscribe |
Erratum (2026-08-07): The HTTP column is wrong for most mutating rows: the deployed API uses PUT (
/set_flag,/move_messages,/send,/save_draft,/new_folder,/subscribe_folder,/unsubscribe_folder,/set_favorite,/set_nav_state) and DELETE (/revoke,/delete_folder,/purge_messages,/empty_trash). Seereact/admin/src/ApiClient.jsfor the authoritative verbs.
/append_sent exists but is an internal SQS consumer that /send enqueues – the client never calls it directly.
Ktor client configuration:
ContentNegotiation with kotlinx.serialization for JSONHttpTimeout (30s connect, 60s request)Logging plugin at LogLevel.HEADERS for debug builds onlyHttpResponseValidator for structured error mappingkit/src/main/kotlin/com/cabalmail/kit/models/ – Kotlin data classes with @Serializable:
Config – runtime configuration from config.jsonFolder – name, delimiter, attributes, unread countEnvelope – uid, from, to, cc, subject, date, flags, hasAttachments, size, threading identity (messageId, inReplyTo, references – lists of angle-bracketed ids, references capped at the newest 20 server-side), auth results (SPF/DKIM/DMARC verdicts), and priority (from X-Priority/Importance). All additive/optional – tolerate their absence on pre-rollout envelopes.Message – envelope + raw body (RFC 822)Address – address string, subdomain, local part, comment, domain, favorite flagAttachment – filename, content type, size, part IDBimiLogo – SVG URL or image dataPreferences – server-synced name / theme / accent / density (see Phase 6)NavState – resume cursor: folder, messageId, uid, uidValidity, listScroll, messageScroll, clientId (stable per-install), updatedAt (server-stamped)Draft – id, from, to/cc/bcc, subject, Markdown body, inReplyTo/references, compose intent, and server coordinates (serverUid/serverUidValidity)Envelopes are the same wire shape across /list_envelopes and /search_envelopes, so a single decoder serves both. Threading fields ride the envelope but the message list stays a flat list – no conversation grouping in either client. They exist so ReplyBuilder can construct a correct References chain (see Phase 5).
Room database keyed by (folder, uid), backing the sliding window so a revisited folder paints from cache before the network refresh lands. LRU-bounded, not a full-folder mirror – Cabalmail mailboxes can be very large, so the client caches the working window plus recent bands rather than syncing every envelope. A STATUS/UIDVALIDITY check invalidates the folder’s cache on mismatch.(folder, uid), evicted LRU with a configurable cap (default 200 MB).StateFlow with invalidation on mutation.kit/ cover: Amplify auth happy path + refresh (mocked), API client token attachment and 401 retry (mocked Ktor engine), JSON deserialization for all model types. These run in test on every PR.EncryptedSharedPreferences, listAddresses() returns expected data, listEnvelopes("INBOX", 1) returns expected messages.First user-visible feature: a functional read-only mail client.
app/.../ui/mail/FolderListScreen.kt – a Compose LazyColumn backed by ApiClient.listFolders().
PullToRefreshBox.ListDetailPaneScaffold or NavigationSuiteScaffold.app/.../ui/mail/MessageListScreen.kt – middle pane on tablet, or navigated-to screen on phone.
ApiClient.listEnvelopes(folder, sort, offset, limit) – positional (offset/limit) paging, not page tokens. Hold a contiguous sliding window of envelopes keyed by list index, load the next/prior band as the user scrolls, and render placeholder rows for not-yet-loaded indices. This mirrors the Apple client’s index-addressed virtualization and keeps large mailboxes responsive. LazyColumn with a scroll-position listener drives the window.fetchBimi), subject, snippet, date, read/unread indicator (from \Seen), attachment icon, flag indicator (from \Flagged), a priority badge when the envelope is flagged important, and an auth-result warning icon when SPF/DKIM/DMARC did not pass.FilterChip row): All / Unread / Flagged, with counts sourced from /folder_status (unseen, flagged). Purely a display narrowing over the loaded window; resets to All on folder switch./list_envelopes.SwipeToDismissBox: swipe left -> moveMessages to Archive or Trash per the “Dispose action” setting (default: Archive); swipe right -> toggle flag / mark-read via setFlag.Search is a first-class scope, not a query parameter on the folder list – matching the Apple client’s MessageListScope.search.
SearchBar opens a cross-folder search backed by ApiClient.searchEnvelopes(query, filters, cursor) (/search_envelopes). With no folder it searches the user’s subscribed folders (excluding Trash) and merges newest-first; each result carries its source folder so per-row operations route correctly.from, to, subject, since/before (day-granular), unread, flagged, has_attachment, and a “this folder only” toggle. No raw IMAP-SEARCH syntax crosses the wire.app/.../ui/mail/MessageDetailScreen.kt – trailing pane on tablet, or navigated-to screen on phone.
ApiClient.fetchBimi), To/Cc, date, subject. Auth-result chips (SPF/DKIM/DMARC pass/fail) and a priority indicator when present.ApiClient.fetchMessage(folder, uid). Messages are never auto-marked-as-read by default – the user explicitly marks read via swipe, toolbar button, or context menu. An opt-in “mark read on open” setting is available (Phase 6) but defaults to off.WebView (AndroidView composable wrapper) with restrictive settings:
settings.javaScriptEnabled = falseWebViewClient that intercepts all URL loads and blocks remote content by defaultWebSettings.setBlockNetworkLoads(true) unless the user taps “Load remote content”setWebContentsDebuggingEnabled(false) in release buildsSelectionContainer { Text(...) }.ApiClient.fetchInlineImage and injecting as data: URIs into the HTML before loading.LazyRow below the body; tap downloads via ApiClient.fetchAttachment (presigned URL) and opens with ACTION_VIEW intent or the system file viewer.ApiClient.purgeMessages (with confirmation); the folder list surfaces an Empty Trash action calling ApiClient.emptyTrash. Both are trash-scoped server-side.No JavaScript execution. Remote content blocked by default via WebSettings.setBlockNetworkLoads(true). A toolbar button (“Load remote content”) toggles network loads for the current message only – does not persist. This mirrors the Apple client’s WKWebView approach.
The Apple client persists a navigation cursor server-side and offers to restore it. The Android client does the same via /get_nav_state / /set_nav_state.
folder, messageId, uid/uidValidity, scroll positions, and a stable per-install clientId) via setNavState.clientId, newer updatedAt), land in INBOX first and surface an opt-in “pick up where you left off” prompt rather than yanking the user elsewhere.clientId is a UUID stored in DataStore (not backed up / not synced) so each install is distinguishable.has_attachment + unread); confirm results span folders and per-row dispose routes to the correct source folder.FromThe feature that differentiates Cabalmail from a generic mail client.
app/.../ui/compose/ComposeScreen.kt – presented as a full-screen activity on phone, or a dialog/new window on tablet.
Fields:
ExposedDropdownMenuBox seeded with listAddresses(), no preselection by default. The Send button is disabled until the user selects or creates an address. If the user has set a default From address in Settings, that address is preselected instead. The menu ends with a “Create new address…” item that opens a bottom sheet (subdomain picker + local-part field + comment) and calls newAddress; on success, the new address is selected.ContactsContract provider (with runtime permission) and/or a learned frequency list in Room.TextField.AnnotatedString buffer, or a minimal rich editor), the canonical form is Markdown and the wire body is the Markdown source. This keeps a draft saved on Android round-trippable on Apple/web and vice versa. The toolbar also carries an “Attach” button using the Photo Picker (PickVisualMedia on API 33+, ACTION_OPEN_DOCUMENT fallback on 31-32) and the document picker (OpenDocument).name preference (/get_preferences), so the header matches across web/Apple/Android.ApiClient.send(). The /send endpoint handles Outbox + Sent server-side (no client-side APPEND) and, when sending from a synced draft, expunges the server draft copy via discard_draft_uid. While sending, the compose screen shows a progress indicator; on success it dismisses; on failure it remains open with a Snackbar error.Triggered from the message detail toolbar. The compose screen opens pre-populated:
ReplyBuilder). Revoked/unknown addresses fall back to no selection.Re: or Fwd: if not already (idempotent – no Re: Re:).In-Reply-To to the original’s Message-ID and build a References chain from the original’s references (preferring the real chain, falling back to [In-Reply-To, Message-ID] for pre-rollout envelopes). Because an open message overlays headers parsed from the fetched body onto the envelope, replies thread correctly even off a cached envelope that predates the threading rollout. Forward deliberately breaks the thread (emits no threading headers).Cross-device draft sync shipped for the Apple clients via the /save_draft endpoint (see docs/draft-sync-and-threading.md); the original “local-only, deferred” plan is superseded. The Android client mirrors that model:
Drafts mailbox via /save_draft and happen (a) on compose close-without-send (always) and (b) on a 60-second debounce while composing – skipped while the body is empty, while a send is running, or while another server save is in flight. Do not shorten the 60s floor: server saves are interactive IMAP writes against the single-task IMAP tier and the debounce bounds Lambda/EFS churn.(uidvalidity, uid) and passes them as replaces_uid / replaces_uidvalidity on the next save (and as discard_draft_uid on send). The endpoint appends the new copy before expunging the old and guards the expunge on matching UIDVALIDITY, so the worst-case failure is a duplicate draft, never a lost one.Drafts folder offers Edit Draft, which seeds compose from the already-fetched message: recipients and subject from the envelope, Bcc and threading from the headers, and the body from the text/plain (Markdown) part – lossless for first-party drafts precisely because compose is Markdown-canonical. An HTML-only foreign draft falls back to editing its HTML through the Markdown buffer.op: discard).Register the app as a share target (<intent-filter> with ACTION_SEND / ACTION_SEND_MULTIPLE) so users can share text, images, and files from other apps directly into the compose screen. The shared content populates the body and/or attachments.
From.Drafts folder on a second device / the web app; confirm the draft appears and Edit Draft re-opens it with recipients, subject, body, and threading intact.In-Reply-To / References headers and confirm they chain correctly. Forward the same message; confirm it starts a new thread (no threading headers).Non-mail features, given their own destinations in the navigation graph.
app/.../ui/addresses/AddressesScreen.kt – mirrors the Apple Addresses tab:
ApiClient.listAddresses(), with swipe-to-delete and long-press context menu calling ApiClient.revokeAddress (with confirmation dialog).ApiClient.setFavorite(address, favorite). Favorited addresses sort to the top of the list and to the top of the Compose From picker, so frequently-used identities are one tap away.ExposedDropdownMenuBox), local-part field, comment field, and “Create” button calling ApiClient.newAddress. Same validation rules as the web and Apple apps.app/.../ui/folders/FoldersAdminScreen.kt – mirrors the Apple Folders tab:
ApiClient.listFolders(); subscribed/unsubscribed state shown.app/.../ui/settings/SettingsScreen.kt – a dedicated navigation destination.
Two storage tiers. A subset of preferences is synced server-side via /get_preferences / /set_preferences so they stay consistent across web, Apple, and Android: the From display name, theme, accent, and density. Everything else is Android-local behavior stored in Jetpack DataStore<Preferences>. (The Apple client syncs name via the server and the rest via iCloud; Android has no iCloud, so routing the shared visual prefs through the server endpoint is both the portable choice and the one that matches the web app.)
Erratum (2026-08-07): iCloud is not used. Since 0.11.x the Apple client syncs its whole settings set through
set_preferences’ per-userappmap (seeAPP_ALLOWED), and the earlierNSUbiquitousKeyValueStoremirroring was removed. The storage-tier split here understates the server-synced set.
Account:
Reading:
| Preference | Options | Default | Storage | Notes |
|---|---|---|---|---|
| Mark as read | Manual / On open / After delay (2s) | Manual | local | Manual = never set \Seen automatically. Matches the Apple client default. |
| Load remote content | Off / Ask / Always | Off | local | Controls whether WebView fetches remote resources. |
| Body render mode | Original / Reader | Original | local | Default for HTML bodies; per-message toggle in the reader overrides it. |
| Folder count display | Unread / Total / Both | Unread | local | What the badge on each folder shows. |
| Default sort | Received / Sent / From / Subject (+ asc/desc) | Received, desc | local | Message-list sort order. |
Composing:
| Preference | Options | Default | Storage | Notes |
|---|---|---|---|---|
| Default From address | None / (list of addresses) | None | local | None = From picker starts empty; Send blocked until user picks. When set, preselects in new-compose (replies still default to original addressee). |
| Signature | Text field | (empty) | local | Plain text, appended at compose time. |
Actions:
| Preference | Options | Default | Storage | Notes |
|---|---|---|---|---|
| Dispose action | Archive / Trash | Archive | local | Controls swipe-left and toolbar dispose throughout the app. |
Appearance:
| Preference | Options | Default | Storage | Notes |
|---|---|---|---|---|
| Theme | System / Light / Dark | System | synced* | light/dark sync to the server; System is an Android-local choice that defers to isSystemInDarkTheme(). |
| Accent | ink / oxblood / forest / azure / amber / plum | forest | synced | The shared palette used by web and Apple; seeds the Material 3 color scheme when dynamic color is off. |
| Density | Compact / Normal / Roomy | Compact | synced | List/row density. |
| Dynamic color | On / Off | On | local | Material You dynamic color from wallpaper (API 31+). An Android-native extra; when on, it takes precedence over the Accent seed. |
About:
\Seen is set.Cross-cutting work to make each form factor feel native, plus robustness improvements.
NavigationBar (bottom) with Mail / Addresses / Folders / Settings destinations.SwipeToDismissBox).android:enableOnBackInvokedCallback="true").WindowInsets handling.sp units throughout.NavigationRail (side) replaces bottom NavigationBar when windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium.ListDetailPaneScaffold for the mail flow (folder list |
message list | detail) with adaptive column widths. |
onKeyEvent for hardware keyboards, mirroring the Apple client’s set (Ctrl in place of Cmd): Ctrl+N compose, Ctrl+R reply, Ctrl+Shift+R reply all, Ctrl+Shift+U toggle read/unread, Ctrl+Shift+L toggle flag, and j/k row navigation.WindowInfoTracker – avoid placing content on the hinge.Local notifications only (no FCM push – same constraint as iOS without APNs).
Erratum (2026-08-07): iOS has had APNs push since 0.11.0. Android starting with local notifications remains a choice (FCM not yet wired), not a parity constraint.
WorkManager periodic background sync (minimum 15 minutes): opens a short API session, fetches folder status, fires a local notification via NotificationCompat for new messages since last check. Notification channel: “New Mail” with default importance.repeatOnLifecycle(Lifecycle.State.RESUMED).ConnectivityManager.NetworkCallback reports no connectivity.CabalmailError sealed class; user-facing messages mapped per subclass.Snackbar for transient errors, AlertDialog for blocking errors.androidx.benchmark.macro for faster cold start.Push notifications (FCM). Same blocker as iOS/APNs: needs a server-side IDLE watcher to bridge to FCM. Tracked alongside APNs work.
Erratum (2026-08-07): The blocker no longer exists: APNs push shipped in 0.11.0 via a procmail delivery hook → SQS (
cabal-push-queue) →push_dispatch, with no IDLE watcher involved. FCM support is now an extension of that shipped pipeline.
CabalmailKit Swift code stays Swift; kit/ is a parallel Kotlin implementation. KMP is a future optimization, not a prerequisite.com.cabalmail.android) registered and the Play Developer API enabled.PLAY_SERVICE_ACCOUNT_JSON GitHub secret.keytool -genkeypair -v -keystore upload.jks -keyalg RSA -keysize 2048 -validity 10000), base64-encoded and stored as ANDROID_KEYSTORE_BASE64. Play App Signing handles the distribution key.EncryptedSharedPreferences integration, and matches the iOS choice. Hand-rolling SRP saves size but costs development time. Default: Amplify.kit/ as android-library vs java-library. If kit/ could avoid Android dependencies it would build faster and be easier to unit test. But Amplify pulls in Android transitively, so android-library is likely required. Revisit if Amplify is replaced.TextField with AnnotatedString supports basic formatting but has no built-in toolbar; the open question is only the editor surface: a minimal custom toolbar (bold/italic/link/list) over a Markdown buffer, versus a WebView-hosted editor reusing the same marked.js/turndown.js pipeline the Apple/web composers use for byte-parity. The canonical format is settled either way. Spike in Phase 5./save_draft shipped (0.10.x) and the Apple clients use it; the Android client syncs drafts server-side rather than staying local-only (see Phase 5 and docs/draft-sync-and-threading.md). No new Lambda needed.