Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Native Apple Client Plan

Context

The React admin app (react/admin/) currently serves as the only Cabalmail client. It works well, but it is a browser app with browser idioms — and even wrapped in a WebView it would feel out of place on iOS, iPadOS, visionOS, and macOS. Version 0.6.x introduces a first-class native client that mirrors the user-facing portions of the React app (mail reading/compose/send, folder management, address creation and revocation, on-the-fly From addresses) without porting any of its code.

Administrative functionality introduced in 0.5.x (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 “Apple client” for 0.6.x:

App Store public release is explicitly not a 0.6.x goal — the roadmap places that at 1.4.x. This phase produces a working client that is continuously built and tested in CI and distributable via TestFlight internal groups.

Approach

Seven phases: project scaffolding and shared Swift package; CI/CD (early, so every subsequent phase runs through it); authentication and transport (IMAP + SMTP + API); mail reading; mail composition including on-the-fly From; address and folder management; platform polish and multiplatform layouts.

Guiding principles

Repository layout

A new top-level directory, sibling to react/admin:

apple/
  Cabalmail.xcworkspace
  Cabalmail/                   # iOS/iPadOS/visionOS app target
    CabalmailApp.swift
    Assets.xcassets
    Info.plist
    Views/
    ViewModels/
  CabalmailMac/                # macOS app target (if not using Catalyst)
  CabalmailKit/                # Swift package: auth, IMAP, SMTP, API, models, caching
    Package.swift
    Sources/CabalmailKit/
      Auth/                    # Cognito sign-in, JWT + IMAP credentials in Keychain
      IMAP/                    # ImapClient actor, IDLE stream, local mirror
      SMTP/                    # SmtpClient actor (submission on 587)
      API/                     # ApiClient actor (addresses, BIMI)
      Models/                  # Shared types
      Cache/                   # Body + envelope caches
    Tests/CabalmailKitTests/
  README.md

Phase 1: Project Scaffolding & Shared Package

1. Xcode project

Create apple/Cabalmail.xcworkspace containing:

2. Runtime configuration

The React app fetches /config.js at runtime from CloudFront, supplying api_url, host, Cognito IDs, and the list of mail domains. The Apple client needs the same values but cannot execute config.js.

Two options, pick one in Phase 1:

Option A is preferred because it keeps the client environment-agnostic (same IPA works against dev/stage/prod by pointing at a different control domain).

3. CabalmailKit — scaffolding

Phase 1 Verification

  1. xcodebuild -workspace apple/Cabalmail.xcworkspace -scheme Cabalmail -destination 'generic/platform=iOS' build succeeds.
  2. xcodebuild test -scheme CabalmailKit succeeds.
  3. Empty iOS app launches in the simulator and shows a placeholder “Hello, Cabalmail” view.

Phase 2: CI/CD

Land the Apple workflow against the Phase 1 scaffold so every subsequent phase develops under green CI. The workflow lives alongside the existing .github/workflows/ pipelines, mirroring their conventions (branch → environment mapping, path-based triggers, shared .github/scripts/ helpers where applicable).

All jobs run on GitHub-hosted macos-15 runners (Apple Silicon, Xcode preinstalled). Because cabal-infra is a public repo, GitHub Actions usage on hosted runners is free — no minute billing, no 10× macOS multiplier. The existing pipelines use Linux runners and aren’t affected.

1. Workflow layout

.github/workflows/apple.yml — triggers on apple/** path changes, pushes to main/stage, and manual workflow_dispatch. Four jobs, all on macos-15:

Job Purpose
kit-test Build and test CabalmailKit across iOS, macOS, visionOS destinations (matrix)
app-build xcodebuild archive for the iOS/iPadOS/visionOS app and the macOS app (matrix of two)
upload-ios Sign the iOS archive, export .ipa, upload to TestFlight (runs on main → prod group; stage → internal group; skipped on PRs)
upload-mac Notarize and staple the macOS archive; upload to TestFlight and attach as a release artifact (same branch scoping)

Environment mapping follows the existing repo convention: main → prod, stage → stage, other branches → development (build + test only; upload jobs skip).

2. Toolchain pinning

3. Linting

4. Code signing

5. TestFlight & notarization

6. Escape hatches (not required for 0.6.x)

If hosted-runner wall-clock ever becomes annoying, the workflow is trivial to point at faster infrastructure:

Both are YAML-only changes to the existing workflow; no code or architectural impact.

Phase 2 Verification

  1. Open a PR touching apple/** (even a README change); confirm kit-test and app-build run and pass against the Phase 1 scaffold.
  2. Confirm the workflow does not run when only react/**, lambda/**, or terraform/** change.
  3. Confirm cache hits on a second run reduce app-build wall-clock noticeably.
  4. Merge to stage; confirm a signed iOS build uploads to the stage TestFlight group and the macOS build is notarized and stapled.

Phase 3: Authentication & Transport

Three transports, unified under a single CabalmailClient actor in CabalmailKit:

  1. Cognito auth — for both issuing the API JWT and validating IMAP/SMTP credentials (Dovecot already Cognito-authenticates via docker/shared/entrypoint.sh).
  2. IMAP/SMTP — direct to Dovecot (993) and Sendmail submission (587) using the user’s Cognito username + password.
  3. API Gateway — for Cabalmail-specific endpoints that aren’t mail protocol operations.

1. Cognito authentication

The React app uses amazon-cognito-identity-js. The Apple analog is AWS Amplify Swift (Amplify + AWSCognitoAuthPlugin), which wraps the same SRP flow and handles token refresh.

CabalmailKit/Sources/CabalmailKit/Auth/AuthService.swift:

Amplify stores the JWT in the Keychain automatically. The IMAP/SMTP password is stored in a separate Keychain item, keyed alongside the username, set at sign-in and cleared at sign-out. Both use the same value today because Dovecot Cognito-auths the user’s actual password.

Amplify alternative: if Amplify’s ~2 MB footprint is objectionable, implement SRP directly against the Cognito IdP InitiateAuth / RespondToAuthChallenge endpoints using URLSession — about a week of work. Decide in Phase 3; default to Amplify for velocity.

2. IMAP client

CabalmailKit/Sources/CabalmailKit/IMAP/ImapClient.swift — an actor wrapping an IMAP library.

Library choice (decide in Phase 3 after a spike):

Operations exposed:

MIME parsing runs client-side. MailCore2 includes a parser; with swift-nio-imap we’d add a small MIME library (swift-mime, or roll our own for the subset we need).

Folder delimiter handling: Dovecot uses . internally; surface paths with / to UI code, translate at the client boundary. (Mirrors the existing Lambda’s .replace("/", ".") normalization.)

3. SMTP client

CabalmailKit/Sources/CabalmailKit/SMTP/SmtpClient.swift — small SMTP submission client.

Either a lightweight third-party package (SwiftSMTP or similar) or ~300 lines hand-rolled. Prefer hand-rolled — submission is a narrow slice of SMTP and avoids another dependency.

4. API client (Cabalmail-specific endpoints)

CabalmailKit/Sources/CabalmailKit/API/ApiClient.swift — an actor for the endpoints that aren’t mail protocol operations:

API method HTTP Endpoint Notes
listAddresses() GET /list Addresses owned by the signed-in user
newAddress(...) POST /new Used by the on-the-fly From picker
revokeAddress(address) DELETE /revoke  
fetchBimi(domain) GET /fetch_bimi Convenience DNS/BIMI lookup; could move client-side later

All requests attach Authorization: <idToken> via a shared URLRequest interceptor that calls authService.currentIdToken(). 401s trigger a single retry after a forced refresh; a second 401 surfaces as AuthError.expired.

5. Caching and offline state

Phase 3 Verification

  1. Unit tests in CabalmailKitTests cover: Cognito sign-in happy path + refresh, IMAP connection/auth + envelope fetch + STORE + MOVE against a mocked IMAP server (swift-nio-imap ships one; for MailCore2 use GreenMail via a Linux Docker container in CI), SMTP submission against a mock, API client token attachment and 401 retry. These run in kit-test on every PR.
  2. Manual: sign in on a dev build; confirm JWT lands in Keychain, IMAP credentials land in Keychain separately, listAddresses() returns expected data, envelopes(folder: "INBOX", range: 1...20) returns expected messages.
  3. Manual: force-expire the JWT; confirm API calls recover silently. Kill the network mid-IMAP session; confirm auto-reconnect.
  4. Manual: subscribe to IDLE on INBOX; send a message to the account from another mailbox; confirm the idle sequence yields an EXISTS event within seconds.

Phase 4: Mail Reading

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

1. Folder sidebar

Cabalmail/Views/FolderListView.swift — a SwiftUI List backed by ImapClient.listFolders(). On iPhone, folders are the root view; on iPad/macOS/visionOS, folders occupy the leading column of a NavigationSplitView.

2. Message list

Cabalmail/Views/MessageListView.swift — middle column of the split view, or pushed view on iPhone.

3. Message view

Cabalmail/Views/MessageDetailView.swift — trailing column / pushed detail.

4. Sanitization

The React app uses DOMPurify. The Apple client’s WKWebView runs in a non-persistent WKWebsiteDataStore with JavaScript disabled by default and all network requests blocked by a WKContentRuleList. Rich HTML displays correctly; scripts, trackers, and remote fetches do not.

Phase 4 Verification

  1. Manual on iPhone simulator: sign in, browse folders, read a message with attachments, download an attachment.
  2. Manual on iPad simulator: confirm three-column layout renders, column widths behave on rotation, keyboard shortcuts (↑/↓/Return) navigate the message list.
  3. Manual on Mac: confirm native window chrome, menu bar File > New Window creates a second window with independent state.
  4. Manual: open a message containing remote tracking pixels; confirm no network request fires until “Load remote content” is tapped.

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

This is the feature that differentiates Cabalmail from a generic IMAP client.

1. Compose scene

Cabalmail/Views/ComposeView.swift — presented as a sheet on iPhone, a new window on iPadOS/macOS (using openWindow with a value-based WindowGroup), and a volumetric window on visionOS.

Fields:

Sending builds an RFC 5322 message client-side (multipart/mixed with multipart/alternative for HTML+plain bodies) and submits it via SmtpClient.send. While sending, the compose window shows a progress overlay; on success it dismisses and the sent message is APPENDed to the Sent folder via ImapClient; on failure it remains open with an error banner.

2. Reply / Reply All / Forward

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

3. Drafts

Drafts persist locally while actively being edited (Core Data or a lightweight Codable store, autosaving every 5 seconds). On compose-window close without send, the draft is APPENDed to the IMAP Drafts folder with the \Draft flag and the local copy cleared. On reopen, the IMAP draft is fetched, edited, and re-APPENDed (old copy flagged \Deleted and expunged). This gives cross-device draft sync for free — a laptop-started reply resumes on the phone.

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

Phase 6: Address & Folder Management

Non-mail features from the React app, given their own tabs (iPhone) or sidebar sections (iPad/macOS/visionOS).

1. Addresses tab (API-backed)

Cabalmail/Views/AddressesView.swift — mirrors react/admin/src/Addresses/, backed by ApiClient:

2. Folders tab (IMAP-backed)

Cabalmail/Views/FoldersAdminView.swift — mirrors react/admin/src/Folders/, backed by ImapClient:

3. Settings / Profile

A new area with no React analog. On iPhone and iPad this is a dedicated Settings tab; on macOS it lives in the Settings scene (⌘,). All preferences stored locally via @AppStorage / UserDefaults, synced across the user’s devices via NSUbiquitousKeyValueStore (iCloud key-value store) so preferences follow the user.

Account:

Reading:

Preference Options Default Notes
Mark as read Manual / On open / After delay (2s) Manual Manual = never set \Seen automatically; user marks read via swipe, toolbar, or context menu. Matches the React app.
Load remote content Off / Ask / Always Off Controls whether WKWebView fetches remote images, fonts, tracking pixels.

Composing:

Preference Options Default Notes
Default From address None / (list of user’s addresses) None None = From picker starts empty; Send is blocked until the user picks or creates an address. When set, that address preselects in new-compose (replies still default to the original’s addressee per 0.3.x semantics).
Signature Text field (empty) Plain text, appended at compose time. Per-address signatures are a future enhancement.

Actions:

Preference Options Default Notes
Dispose action Archive / Trash Archive Controls the left-swipe and toolbar dispose button throughout the app.

Appearance:

Preference Options Default Notes
Theme System / Light / Dark System Maps to .preferredColorScheme.

About:

Phase 6 Verification

  1. Manual: create, then revoke an address; confirm it disappears from the picker in Compose.
  2. Manual: create a nested folder, subscribe/unsubscribe, delete; confirm changes reflect in the sidebar.
  3. Manual: change signature, compose a new message, confirm signature appended.
  4. Manual: open a message; confirm it stays unread (default mark-as-read: 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 that address is preselected. Clear the setting; open a new compose; confirm the From picker is empty and Send is disabled.
  6. Manual: with Dispose set to Archive, swipe-left a message; confirm it lands in Archive. Switch to Trash; repeat; confirm it lands in Trash.
  7. Manual: toggle theme to Dark; confirm the app switches immediately. Toggle back to System.

Phase 7: Platform Polish

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

1. iPhone

2. iPad

3. macOS

4. visionOS

5. IDLE-based foreground push

6. Offline reading

7. Error handling and telemetry

Phase 7 Verification

  1. Manual per platform: run through the golden path (sign in → browse → read → reply → send → revoke address) and confirm it feels native.
  2. Accessibility Inspector audit on iOS and macOS — zero critical issues.
  3. Airplane mode test: confirm cached messages remain readable; confirm queued sends fire on reconnect.

Out of Scope for 0.6.x

Prerequisites

Open Questions

  1. Mac Catalyst vs native macOS target — decide in Phase 1 after a spike.
  2. IMAP library: MailCore2 vs swift-nio-imap — decide in Phase 3 after a spike. Key criteria: visionOS build cleanliness, IDLE ergonomics, MIME parser availability.
  3. Amplify Swift vs hand-rolled Cognito SRP — decide in Phase 3 based on Amplify’s binary size on an empty project.
  4. Sent-message APPEND — some IMAP servers auto-append submitted mail to Sent when SMTP submission is configured to do so. Confirm Sendmail+Dovecot’s behavior; if it auto-appends, skip the client-side APPEND to avoid duplicates.