Host your own email and enhance your privacy
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.
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.
NavigationSplitView, List, .searchable, Menu, share sheet, .contextMenu, swipe actions, etc. The goal is an app that feels at home next to Mail.app, not a translation of the web UI.list_envelopes, fetch_message, move_messages, send, etc.) continue to back the React app unchanged.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
Create apple/Cabalmail.xcworkspace containing:
Cabalmail app target: iOS 18+, iPadOS 18+, visionOS 2+ deployment targets, SwiftUI lifecycle (@main App).CabalmailKit Swift package: the shared networking/models/auth layer, consumed by every app target.Scene / window management, Settings scene, menu bar). Default is native macOS target because the roadmap treats macOS as a first-class platform; revisit if Phase 7 polish reveals unacceptable duplication.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:
https://{control_domain}/config.json with the same values config.js currently emits. The Apple client fetches it on first launch and caches it in UserDefaults. Requires a one-line addition in terraform/infra/modules/app/ (or the module that writes config.js) to emit a JSON sibling.Config.xcconfig values per build configuration (dev/stage/prod). Simpler but couples the app version to an environment.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).
CabalmailKit — scaffoldingPackage.swift declaring platforms (iOS 18, macOS 15, visionOS 2) and product CabalmailKit.Auth/, IMAP/, SMTP/, API/, Models/, Cache/, Config/.CabalmailClient actor that will own the auth session and expose the IMAP/SMTP/API surfaces to the app.xcodebuild -workspace apple/Cabalmail.xcworkspace -scheme Cabalmail -destination 'generic/platform=iOS' build succeeds.xcodebuild test -scheme CabalmailKit succeeds.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.
.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).
maxim-lobanov/setup-xcode@v1 with an explicit xcode-version (e.g. '16.1') so builds are reproducible across runner image rotations.actions/cache for ~/Library/Developer/Xcode/DerivedData and Swift Package Manager artifacts, keyed on hashes of Package.resolved and project.pbxproj. Target: warm app-build under 10 minutes.'platform=iOS Simulator,name=iPhone 16,OS=18.1', 'platform=visionOS Simulator,name=Apple Vision Pro') — simulator lists vary by runner image.swiftlint runs in kit-test on both CabalmailKit/ and the app sources, mirroring the existing pylint / tflint jobs.xcodebuild warnings promoted to errors for release configurations..p12, base64-encode, store as APPLE_DISTRIBUTION_CERT_P12 and APPLE_DISTRIBUTION_CERT_PASSWORD secrets. At job start, import into a temporary keychain (security create-keychain, security import, security set-key-partition-list) that is deleted at job end.APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_API_ISSUER_ID, and the .p8 key (base64) as secrets. Used for provisioning-profile fetch and TestFlight upload.xcrun altool --upload-app with the App Store Connect API key. Build number is $ for monotonicity; marketing version read from Info.plist. A post-upload step posts the TestFlight build URL to the workflow summary.xcrun notarytool submit --wait followed by xcrun stapler staple.If hosted-runner wall-clock ever becomes annoying, the workflow is trivial to point at faster infrastructure:
[self-hosted, macOS, arm64]; warm-cache incremental builds typically land in a third of hosted wall-clock. Free (GitHub doesn’t charge for self-hosted on any plan), available on every plan. Using self-hosted on a public repo wants the PR-on-hosted / push-on-self-hosted split to keep the fork-PR attack surface bounded, plus ephemeral runner mode (--ephemeral) or Tart VMs for isolation.Both are YAML-only changes to the existing workflow; no code or architectural impact.
apple/** (even a README change); confirm kit-test and app-build run and pass against the Phase 1 scaffold.react/**, lambda/**, or terraform/** change.app-build wall-clock noticeably.stage; confirm a signed iOS build uploads to the stage TestFlight group and the macOS build is notarized and stapled.Three transports, unified under a single CabalmailClient actor in CabalmailKit:
docker/shared/entrypoint.sh).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:
signIn(username:password:), signUp(username:password:email:phone:), confirmSignUp(username:code:)forgotPassword(username:) / confirmForgotPassword(username:code:newPassword:)signOut()currentIdToken() async throws -> String — fresh token for API calls; refreshes if neededcurrentImapCredentials() async throws -> (username: String, password: String) — reads from Keychain; returned to the IMAP/SMTP layers, never loggedAmplify 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.
CabalmailKit/Sources/CabalmailKit/IMAP/ImapClient.swift — an actor wrapping an IMAP library.
Library choice (decide in Phase 3 after a spike):
Operations exposed:
listFolders() — LIST "" "*" + LSUB for subscribed set; returns hierarchical folder tree.createFolder(_:parent:) / deleteFolder(_:) — CREATE / DELETE.subscribe(_:) / unsubscribe(_:) — SUBSCRIBE / UNSUBSCRIBE.status(_:) — STATUS (UNSEEN MESSAGES RECENT UIDVALIDITY UIDNEXT) for unread badges.envelopes(folder:range:) — UID FETCH n:m (ENVELOPE FLAGS BODYSTRUCTURE INTERNALDATE RFC822.SIZE).fetchBody(folder:uid:) — UID FETCH uid BODY.PEEK[]; returns raw RFC 822 for client-side MIME parsing.fetchPart(folder:uid:partId:) — UID FETCH uid BODY.PEEK[partId] for attachments and inline images.setFlags(folder:uids:flags:add:) — UID STORE.move(folder:uids:destination:) — UID MOVE (RFC 6851) with fallback to COPY+STORE \Deleted+EXPUNGE for servers without MOVE.append(folder:message:flags:) — for saving drafts to the Drafts folder.search(folder:query:) — UID SEARCH with server-side criteria.idle(folder:) — async sequence yielding EXISTS / EXPUNGE / FETCH events; used by Phase 7 for foreground push.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.)
CabalmailKit/Sources/CabalmailKit/SMTP/SmtpClient.swift — small SMTP submission client.
send(message:) async throws — single method; compose layer builds the Message value.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.
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.
Codable store in the app support directory. On reconnect, STATUS + UID FETCH since last-known UIDNEXT catches up incrementally.localStorage.removeItem(ADDRESS_LIST) pattern.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.listAddresses() returns expected data, envelopes(folder: "INBOX", range: 1...20) returns expected messages.idle sequence yields an EXISTS event within seconds.First user-visible feature: a functional read-only mail client.
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.
STATUS (UNSEEN) per folder — available from day one since the transport speaks IMAP directly..refreshable) triggers LIST + a STATUS sweep.Cabalmail/Views/MessageListView.swift — middle column of the split view, or pushed view on iPhone.
ImapClient.envelopes(folder:range:) with page-based lazy loading (onAppear on the last row fetches the next page of UIDs).\Seen), attachment paperclip (from BODYSTRUCTURE), flag (from \Flagged).ImapClient.move to Archive or Trash per the “Dispose action” preference (default: Archive); flag/mark-read (right) → ImapClient.setFlags..searchable wired to IMAP UID SEARCH — server-side full-text search is free and available from day one..contextMenu mirrors the swipe actions for Mac/iPad pointer users.Cabalmail/Views/MessageDetailView.swift — trailing column / pushed detail.
ApiClient.fetchBimi), To/Cc, date, subject.ImapClient.fetchBody(folder:uid:) using BODY.PEEK[] (does not set \Seen). Messages are never auto-marked-as-read — the user explicitly marks read via swipe, toolbar button, or context menu, which fires ImapClient.setFlags. This matches the React app’s behavior. An opt-in “mark read on open” setting is available (see Phase 6 Settings) but defaults to off.WKWebView wrapper (UIViewRepresentable) with a restrictive content policy — no remote content by default (per the “Load remote content” preference, default: off), a “Load remote content” toolbar button reveals it. Plain-text bodies render in a Text with .textSelection(.enabled).ImapClient.fetchPart(folder:uid:partId:) and rewriting cid: URLs to local file URLs before the HTML is loaded.QLPreviewController / NSPreviewPanel.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.
FromThis is the feature that differentiates Cabalmail from a generic IMAP client.
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:
Menu / Picker 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 picker ends with a “Create new address…” item that presents an inline sheet (subdomain picker + local-part field + comment) and calls newAddress; on success, the new address is selected and the picker closes.CabalmailKit/Cache/).TextEditor with AttributedString on iOS 18+/macOS 15+ handles bold/italic/links/lists. For OS versions where attributed TextEditor is insufficient, wrap UITextView / NSTextView as UIViewRepresentable / NSViewRepresentable. Toolbar provides formatting controls plus an “Attach” button using PhotosPicker and fileImporter.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.
Triggered from the message detail toolbar. The compose scene opens pre-populated:
Re: or Fwd: if not already.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.
From.Non-mail features from the React app, given their own tabs (iPhone) or sidebar sections (iPad/macOS/visionOS).
Cabalmail/Views/AddressesView.swift — mirrors react/admin/src/Addresses/, backed by ApiClient:
ApiClient.listAddresses(), with a trailing revoke button (.swipeActions + long-press .contextMenu, confirmation alert) that calls ApiClient.revokeAddress.ApiClient.newAddress. Same validation rules as the web form.Cabalmail/Views/FoldersAdminView.swift — mirrors react/admin/src/Folders/, backed by ImapClient:
LIST "" "*"; subscribed set from LSUB.UNSUBSCRIBE); unsubscribed folders get a subscribe action (SUBSCRIBE).CREATE parent.name.DELETE.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:
\Seen is set.Cross-cutting work to make each platform feel native, plus robustness improvements.
NavigationSplitView (folders |
messages | detail) with .balanced column visibility. |
.handlesExternalEvents) — compose in its own scene..toolbar(customizationBehavior:).Settings scene for preferences (replaces the mobile Settings tab on macOS).NSSharingServicePicker for share actions where SwiftUI ShareLink is insufficient.WindowGroup defaults (folders+messages in one window; compose and detail open as side volumes / additional windows).ImapClient.idle(folder: "INBOX") keeps an IMAP IDLE connection open; EXISTS events trigger an immediate envelope fetch and update the message list and unread badge. No server-side infrastructure required.UNUserNotificationCenter) for newly arriving mail. Not true push — the connection dies within a minute or two — but it covers the common case of quickly checking another app and returning.BGAppRefreshTask on iOS / NSBackgroundActivityScheduler on macOS periodically opens a short-lived IMAP session and catches up via STATUS + UID FETCH since UIDNEXT, so unread badges are fresh at launch even when IDLE hasn’t been running.APPEND to Drafts) on reconnect.CabalmailError type; user-facing messages mapped per case.MetricKit (no third-party SDK).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.