Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Native Android Client Plan

Context

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:

Scope of “Android client” for 1.1.0:

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.

Approach

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.

Guiding principles

Lessons from the Apple client

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_AUTH JSON client (CabalmailKit/Auth/AuthService.swift); Amplify Android should be weighed on its own merits, not as a mirror of the Apple choice.

Stack decisions

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

Repository layout

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.


Phase 1: Project Scaffolding & Shared Module

1. Gradle project

Create android/ containing:

2. Runtime configuration

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:

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_DOMAIN survives 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.

3. kit/ module – scaffolding

4. App shell

Phase 1 verification

  1. cd android && ./gradlew assembleDebug succeeds.
  2. cd android && ./gradlew :kit:test succeeds.
  3. Empty app launches in the Android Emulator (Pixel 8, API 35) and shows “Hello, Cabalmail” with dynamic color theming.

Phase 2: CI/CD

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.

1. Workflow layout

.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.

2. Toolchain pinning

3. Linting

4. App signing

Android signing is simpler than Apple signing – no provisioning profiles, no certificate import ceremony.

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.

5. Play Console upload

6. Workflow skeleton

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

Phase 2 verification

  1. Open a PR touching android/**; confirm test and build run and pass against the Phase 1 scaffold.
  2. Confirm the workflow does not run when only react/**, apple/**, or terraform/** change.
  3. Confirm Gradle cache hits on a second run reduce build wall-clock noticeably.
  4. Merge to stage; confirm a signed AAB uploads to the Play Console internal testing track.

Phase 3: Authentication & API Client

A single transport layer – the Lambda API surface – unified under CabalmailClient in kit/.

1. Cognito authentication

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_AUTH in AuthService.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:

Amplify initialization happens in CabalmailApp.kt (Application.onCreate), configured programmatically from the config.json values (no amplifyconfiguration.json file – the config is fetched at runtime).

2. API client

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). See react/admin/src/ApiClient.js for the authoritative verbs.

/append_sent exists but is an internal SQS consumer that /send enqueues – the client never calls it directly.

Ktor client configuration:

3. Models

kit/src/main/kotlin/com/cabalmail/kit/models/ – Kotlin data classes with @Serializable:

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).

4. Caching

Phase 3 verification

  1. Unit tests in 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.
  2. Manual: sign in on a dev build; confirm token stored in EncryptedSharedPreferences, listAddresses() returns expected data, listEnvelopes("INBOX", 1) returns expected messages.
  3. Manual: force-expire the JWT; confirm API calls recover silently.
  4. Manual: kill the app and relaunch; confirm session restores without re-authentication.

Phase 4: Mail Reading

First user-visible feature: a functional read-only mail client.

1. Folder list

app/.../ui/mail/FolderListScreen.kt – a Compose LazyColumn backed by ApiClient.listFolders().

2. Message list

app/.../ui/mail/MessageListScreen.kt – middle pane on tablet, or navigated-to screen on phone.

Search is a first-class scope, not a query parameter on the folder list – matching the Apple client’s MessageListScope.search.

3. Message detail

app/.../ui/mail/MessageDetailScreen.kt – trailing pane on tablet, or navigated-to screen on phone.

4. Sanitization

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.

5. Resume cursor

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.

Phase 4 verification

  1. Manual on phone emulator (Pixel 8, API 35): sign in, browse folders, read a message with attachments, download an attachment.
  2. Manual on tablet emulator (Pixel Tablet, API 35): confirm adaptive layout renders folder list + message list side by side, detail opens in trailing pane.
  3. Manual: open a message containing remote tracking pixels; confirm no network request fires until “Load remote content” is tapped.
  4. Manual: pull-to-refresh on the message list; confirm new messages appear.
  5. Manual: scroll a large folder (thousands of messages); confirm placeholder rows fill in as bands load and memory stays flat.
  6. Manual: toggle the All / Unread / Flagged pills and change sort; confirm the list and counts respond.
  7. Manual: run a cross-folder search with a filter (e.g. has_attachment + unread); confirm results span folders and per-row dispose routes to the correct source folder.
  8. Manual: multi-select several messages and bulk-move; confirm all move and selection clears.
  9. Manual: on this device, open a message and background the app; on a second device (or the web app), confirm the resume prompt offers that position on next launch. In a trash folder, permanently delete a message and confirm Empty Trash clears it.

Phase 5: Mail Composition & On-the-Fly From

The feature that differentiates Cabalmail from a generic mail client.

1. Compose screen

app/.../ui/compose/ComposeScreen.kt – presented as a full-screen activity on phone, or a dialog/new window on tablet.

Fields:

2. Reply / Reply All / Forward

Triggered from the message detail toolbar. The compose screen opens pre-populated:

3. Drafts – local buffer + cross-device sync

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:

4. Share target

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.

Phase 5 verification

  1. Manual: compose and send to a personal address, confirm delivery and correct From.
  2. Manual: in compose, open the From picker, create a new address, confirm it becomes the selected From and appears in the Addresses screen.
  3. Manual: reply to a message, confirm From defaults to the addressee of the original.
  4. Manual: kill the app mid-compose, relaunch, confirm draft restored from the local buffer.
  5. Manual: share an image from the Photos app into Cabalmail; confirm it appears as an attachment in compose.
  6. Manual: start a draft, wait for the 60s server save (or close without sending), then open the 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.
  7. Manual: reply within a thread; inspect the sent message’s In-Reply-To / References headers and confirm they chain correctly. Forward the same message; confirm it starts a new thread (no threading headers).

Phase 6: Address & Folder Management + Settings

Non-mail features, given their own destinations in the navigation graph.

1. Addresses screen

app/.../ui/addresses/AddressesScreen.kt – mirrors the Apple Addresses tab:

2. Folders screen

app/.../ui/folders/FoldersAdminScreen.kt – mirrors the Apple Folders tab:

3. Settings

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-user app map (see APP_ALLOWED), and the earlier NSUbiquitousKeyValueStore mirroring 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:

Phase 6 verification

  1. Manual: create, then revoke an address; confirm it disappears from the From picker in Compose.
  2. Manual: create a nested folder, subscribe/unsubscribe, delete; confirm changes reflect in the folder list.
  3. Manual: change signature, compose a new message, confirm signature appended.
  4. Manual: open a message; confirm it stays unread (default: manual). Change setting to “On open”; open a message; confirm \Seen is set.
  5. Manual: set Default From to an address; open a new compose; confirm preselected. Clear the setting; confirm From picker is empty and Send is disabled.
  6. Manual: toggle theme to Dark; confirm immediate switch. Toggle Dynamic color off; confirm Material 3 falls back to the selected Accent seed. Change Density; confirm row spacing responds.
  7. Manual: change theme/accent/density on Android, then open the web app; confirm the same values apply (server-synced). Set a Display name and send a message; confirm the From header carries it.
  8. Manual: favorite an address; confirm it sorts to the top of both the Addresses list and the Compose From picker.

Phase 7: Platform Polish

Cross-cutting work to make each form factor feel native, plus robustness improvements.

1. Phone

2. Tablet / foldable

3. Notifications

4. Offline reading

5. Error handling

6. Performance

Phase 7 verification

  1. Manual per form factor: run the golden path (sign in -> browse -> read -> reply -> send -> revoke address) on phone and tablet emulators.
  2. Accessibility Scanner audit – zero critical issues.
  3. Airplane mode test: confirm cached messages remain readable; confirm queued sends fire on reconnect.
  4. Rotate device mid-compose; confirm no state loss.
  5. Split-screen / picture-in-picture: confirm the app handles configuration changes gracefully.

Out of Scope for 1.1.0

Prerequisites

Open Questions

  1. HTTP client: Ktor vs Retrofit/OkHttp. Ktor is more Kotlin-idiomatic and keeps a KMP door open; Retrofit has a larger community and more sample code. Both work. Default: Ktor.
  2. Amplify Android vs hand-rolled Cognito SRP. Amplify adds ~3-4 MB after R8 but provides token management, EncryptedSharedPreferences integration, and matches the iOS choice. Hand-rolling SRP saves size but costs development time. Default: Amplify.
  3. 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.
  4. Rich text compose (Markdown-canonical). The export target is Markdown, not HTML – the first-party composers persist Markdown and emit the Markdown source as the text part so drafts round-trip losslessly across clients (see Phase 5). Compose’s 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.
  5. Cross-device draft sync – resolved. /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.