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.
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 server-side |
Same – no client-side APPEND |
MIME parsing for fetchPart |
Fetch full body, parse MIME client-side | Same approach in Kotlin |
| 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:
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.
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 |
listEnvelopes(folder, page) |
GET | /list_envelopes |
Paginated envelope list |
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 IMAP flags |
moveMessages(folder, uids, dest) |
POST | /move_messages |
Move between folders |
send(message) |
POST | /send |
Send; handles Outbox + Sent server-side |
listAddresses() |
GET | /list |
User’s addresses |
newAddress(subdomain, local, comment) |
POST | /new |
Create address |
revokeAddress(address) |
DELETE | /revoke |
Delete address |
fetchBimi(domain) |
GET | /fetch_bimi |
BIMI logo lookup |
listFoldersAdmin() |
GET | /list_folders |
For folder management |
newFolder(name, parent) |
POST | /new_folder |
Create folder |
deleteFolder(name) |
DELETE | /delete_folder |
Delete folder |
subscribeFolder(name) |
POST | /subscribe_folder |
Subscribe |
unsubscribeFolder(name) |
POST | /unsubscribe_folder |
Unsubscribe |
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, sizeMessage – envelope + raw body (RFC 822)Address – address string, subdomain, local part, comment, domainAttachment – filename, content type, size, part IDBimiLogo – SVG URL or image dataRoom database keyed by (folder, uid). On reconnect, fetch only UIDs newer than the last cached UID.(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, page) with page-based lazy loading (LazyColumn with onAppear-equivalent triggering next page fetch when the last item is composed).\Seen), attachment icon (from envelope metadata), flag indicator (from \Flagged).SwipeToDismissBox: swipe left -> moveMessages to Archive or Trash per the “Dispose action” setting (default: Archive); swipe right -> toggle flag/mark-read via setFlag.SearchBar wired to the /list_envelopes search parameter – server-side search.app/.../ui/mail/MessageDetailScreen.kt – trailing pane on tablet, or navigated-to screen on phone.
ApiClient.fetchBimi), To/Cc, date, subject.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.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.
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.TextField with AnnotatedString support, or a minimal rich-text editor (bold/italic/links/lists). Toolbar provides formatting controls plus an “Attach” button using the Photo Picker (PickVisualMedia contract on API 33+, ACTION_OPEN_DOCUMENT fallback on 31-32) and the document picker (OpenDocument contract).ApiClient.send(). The /send endpoint handles Outbox + Sent server-side (no client-side APPEND). 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:
Re: or Fwd: if not already.Drafts persist locally while being edited (Room database, autosaving every 5 seconds). On compose-screen close without send, the draft remains in Room for the next session. Cross-device draft sync (via IMAP Drafts folder) is deferred – the API surface doesn’t expose APPEND directly, and /send is the only write path. Local-only drafts are sufficient for 1.1.x.
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.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).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. All preferences stored via Jetpack DataStore<Preferences>.
Account:
Reading:
| Preference | Options | Default | Notes |
|---|---|---|---|
| Mark as read | Manual / On open / After delay (2s) | Manual | Manual = never set \Seen automatically. Matches the Apple client default. |
| Load remote content | Off / Ask / Always | Off | Controls whether WebView fetches remote resources. |
Composing:
| Preference | Options | Default | Notes |
|---|---|---|---|
| Default From address | None / (list of addresses) | None | 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) | Plain text, appended at compose time. |
Actions:
| Preference | Options | Default | Notes |
|---|---|---|---|
| Dispose action | Archive / Trash | Archive | Controls swipe-left and toolbar dispose throughout the app. |
Appearance:
| Preference | Options | Default | Notes |
|---|---|---|---|
| Theme | System / Light / Dark | System | Maps to AppCompatDelegate.setDefaultNightMode() or Compose isSystemInDarkTheme(). |
| Dynamic color | On / Off | On | Material You dynamic color from wallpaper. API 31 guarantees support. |
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: Ctrl+N compose, Ctrl+R reply, Ctrl+Shift+R reply all, j/k navigation.WindowInfoTracker – avoid placing content on the hinge.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.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 lacks a built-in toolbar or HTML export. Options: minimal custom toolbar (bold/italic/link only, export to HTML manually), or a third-party rich-text editor library. Spike in Phase 5.APPEND. Drafts are local-only in 1.1.x. If cross-device drafts are important, a /save_draft Lambda could be added in a future version.