Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Cabalmail Apple Client

Native iOS / iPadOS / visionOS / macOS / watchOS client for Cabalmail. The original implementation plan is preserved at docs/0.6.x/ios-client-plan.md for historical context; this document describes the as-implemented state.

Layout

apple/
  project.yml                # XcodeGen spec (generates Cabalmail.xcodeproj)
  Cabalmail.xcworkspace/     # Workspace referencing the generated project + kit package
  CabalmailUI/               # Shared app layer (views, view models, app state) as one
                             #   module, linked by both apps; see "Shared app layer" below
  Cabalmail/                 # iOS / iPadOS and visionOS app sources: entry point, App
                             #   Intents, Info.plists, entitlements, asset catalogs. Two
                             #   targets compile this folder — `Cabalmail` (iPhone / iPad)
                             #   and `CabalmailVision` (see "visionOS is its own target")
  CabalmailMac/              # Native macOS app target: entry point, menus, Settings
                             #   window, menu-bar extra, asset catalogs
  CabalmailWatch/            # Watch companion app (address management only),
                             #   embedded in the iOS product
  CabalmailKit/              # Shared Swift package — networking, models, auth, caching;
                             #   its second product, CabalmailShared, holds what the app
                             #   extensions share with the apps (see "Extension-shared
                             #   values" below)

Bootstrap

The .xcodeproj is not committed. Generate it before opening the workspace. The rich-text composer’s marked + turndown bundles are also not committed (see Rich-text editor) — they materialize from react/admin/node_modules/ via a sync script. Run both before your first swift test or xcodebuild:

brew install xcodegen node    # one-time
cd apple
xcodegen generate
scripts/sync-vendored.sh      # fetches marked + turndown into CabalmailKit
open Cabalmail.xcworkspace

CI (.github/workflows/apple.yml) runs both steps before every xcodebuild and swift test invocation, so contributors never need to commit generated project files or vendored JS.

Re-run scripts/sync-vendored.sh any time react/admin/package.json bumps the marked or turndown version (swift test will fail with a missing-resource error if you skip it).

Prerequisites for local builds

Verification

From apple/ after xcodegen generate:

# 1. App builds for iOS (unsigned; signing is only needed for archive/upload)
xcodebuild -workspace Cabalmail.xcworkspace \
           -scheme Cabalmail \
           -destination 'generic/platform=iOS' \
           CODE_SIGNING_ALLOWED=NO \
           CODE_SIGNING_REQUIRED=NO \
           CODE_SIGN_IDENTITY="" \
           build

# 2. Kit package tests pass
swift test --package-path CabalmailKit

# 3. Launch in the simulator and see "Hello, Cabalmail".
#    Easiest path: open Cabalmail.xcworkspace in Xcode, pick the Cabalmail
#    scheme and any iPhone simulator your Xcode ships, and press ⌘R.
#
#    Headless equivalent (uses the default DerivedData location — do NOT
#    pass -derivedDataPath into the repo tree if the repo lives under an
#    iCloud-synced directory, or codesign will reject the .app with
#    "resource fork, Finder information, or similar detritus not allowed").
#    Pick any iPhone simulator name that exists in your `xcrun simctl list
#    devices` output:
SIM='iPhone 17 Pro'   # adjust to whatever your Xcode version ships

xcodebuild -workspace Cabalmail.xcworkspace \
           -scheme Cabalmail \
           -destination "platform=iOS Simulator,name=$SIM" \
           build

APP_PATH=$(xcodebuild -workspace Cabalmail.xcworkspace \
                      -scheme Cabalmail \
                      -destination "platform=iOS Simulator,name=$SIM" \
                      -showBuildSettings build 2>/dev/null \
           | awk '/ BUILT_PRODUCTS_DIR = /{print $3}')/Cabalmail.app
xcrun simctl boot "$SIM" 2>/dev/null || true
open -a Simulator
xcrun simctl install booted "$APP_PATH"
xcrun simctl launch booted com.cabalmail.Cabalmail

Signing

DEVELOPMENT_TEAM is deliberately unset in project.yml. Three contexts:

Context How the team ID is supplied
Local (Xcode or xcodebuild) Copy Local.xcconfig.example → Local.xcconfig and fill in DEVELOPMENT_TEAM. Gitignored. project.yml references it via configFiles, so every target picks it up automatically.
Headless build without a team ID Pass CODE_SIGNING_ALLOWED=NO (see verification commands above)
Headless archive (CI upload jobs) xcodebuild ... DEVELOPMENT_TEAM=$APPLE_TEAM_ID archive, team ID sourced from a GitHub secret. Command-line overrides beat the xcconfig, so CI doesn’t need the file.

Setup once:

cd apple
cp Local.xcconfig.example Local.xcconfig
# edit Local.xcconfig, set DEVELOPMENT_TEAM to your team ID
xcodegen generate

After that, plain xcodebuild ... build and xcodebuild ... archive both sign cleanly.

Local.xcconfig is also where a local build sets CABALMAIL_CONTROL_DOMAIN, the control domain baked into the associated-domains entitlement so password managers can match the app to the web app’s saved login; CI passes it on the archive command line. See password-autofill.md.

Simulator testing and automation

Tooling for driving the full app in an iOS simulator — scripted scenarios, agent-driven exploration, and reproducing bugs that only surface in the real app.

Sign-in-capable simulator build: scripts/build-sim.sh

cd apple
scripts/build-sim.sh            # build + install onto the booted simulator
scripts/build-sim.sh <UDID>     # ... onto a specific simulator
scripts/build-sim.sh -          # build only

The invocation it wraps is non-obvious, and getting it wrong produces an app that builds but cannot sign in:

Driving the installed app: Tools/SimDrive

A small XCUITest command REPL. The host writes command files into an exchange directory; the test executes each against the installed app and writes a JSON result back. Because it runs inside XCUITest, it sees the app’s accessibility tree — the same interface VoiceOver uses — rather than pixels.

cd apple/Tools/SimDrive
./simdrive start                          # boots the runner (blocks until ready)
./simdrive cmd launch                     # launch the app
./simdrive cmd tap id:folder.row.INBOX    # address controls by identifier
./simdrive cmd wait id:message.row.42 timeout:15
./simdrive cmd swiperow id:message.row.42 edge:trailing hold:2
./simdrive cmd dump                       # full accessibility tree
./simdrive stop

The full command grammar (launch, activate, env, dump, sysdump, sysapp, focus, tap, type, cmdv, orient, drag, swiperow, pscroll, scroll, exists, wait) is documented at the top of Tools/SimDrive/SimDriveUITests/SimDriveTests.swift. Notes that keep sessions out of known potholes:

Apple Developer account setup

This is the one-time manual setup required to enable CI uploads to TestFlight. Run through it in order once per team. Individual substeps are expanded in the sections further down.

  1. Enroll the team at developer.apple.com if it isn’t already. Confirm your 10-character Team ID at Membership details; this becomes the APPLE_TEAM_ID secret.
  2. Create the Apple Distribution certificate. Xcode → Settings → Accounts → select your Apple ID and team → Manage Certificates… → + → Apple Distribution. See Exporting the distribution certificate for the export flow that produces APPLE_DISTRIBUTION_CERT_P12 / APPLE_DISTRIBUTION_CERT_PASSWORD.
  3. (macOS only) Create a Mac Installer Distribution certificate. The .pkg that wraps the .app at Mac App Store submission time needs its own cert — Apple Distribution signs the .app, Mac Installer Distribution signs the .pkg. See Exporting the Mac Installer certificate.
  4. Register the App Group and bundle identifiers in the Developer portal at developer.apple.com → Certificates, Identifiers & Profiles → Identifiers → +.

    First the App Group (the shared container the push Notification Service Extension reads; select App Groups on the + screen):

    • group.com.cabalmail.Cabalmail (description: Cabalmail)

    Then the App IDs, with the capabilities each one needs checked at registration time (capabilities added later invalidate any profiles already issued against the App ID).

    The macOS profile trap. The current portal registers every new App ID as platform-universal (iOS, iPadOS, macOS, ...) and offers no platform choice anywhere — not at registration, not in the profile flow — and a profile generated through the portal UI against a universal App ID comes out iOS-family only (Platform: iOS, xrOS, visionOS, no OSX), which a macOS target rejects at archive time (“has platforms iOS…, which does not match the current platform macOS”). Profiles for macOS targets on universal App IDs must instead be minted through the App Store Connect API, where the profile type is explicit — scripts/make-mac-profile.py does it in one call using the same ASC API key CI uploads with (which is why that .p8 must be kept at hand; see Creating the App Store Connect API key). Verify any mac profile before uploading its secret: security cms -D -i <file> | plutil -p - | grep -A6 Platform must list OSX — the download’s file extension is not a reliable signal.

    App ID Description Capabilities
    com.cabalmail.Cabalmail Cabalmail Push Notifications; App Groups (configure → tick group.com.cabalmail.Cabalmail); Associated Domains (Password AutoFill, see password-autofill.md)
    com.cabalmail.Cabalmail.NotificationService Cabalmail Notification Service App Groups (same group). Not Push Notifications — the extension never registers for push itself; it only reads the shared containers
    com.cabalmail.Cabalmail.watchkitapp Cabalmail Watch none
    com.cabalmail.CabalmailMac Cabalmail Mac Push Notifications; App Groups (same group); Associated Domains
    com.cabalmail.CabalmailMac.NotificationService Cabalmail Mac Notification Service App Groups (same group), same rationale as the iOS extension

    Keychain sharing (the app and the extension share a keychain access group) needs no portal capability — profiles honor the keychain-access-groups entitlement for any team-prefixed group automatically.

    The APNs authentication key that the server’s push_dispatch Lambda signs with is a separate, CI-unrelated credential (Keys → +, no role, one per team covers every app); see docs/push-notifications.md for creating and seeding it.

    CI uses manual code signing, so App IDs must exist before you create the matching provisioning profiles in the next step.

  5. Create the provisioning profiles for each App ID. See Creating provisioning profiles — produces the IOS_APP_STORE_PROFILE / IOS_NSE_APP_STORE_PROFILE / WATCHOS_APP_STORE_PROFILE / MAC_APP_STORE_PROFILE / (optional) MAC_DEVID_PROFILE secrets.
  6. Create an App Store Connect API key with the App Manager role. See Creating the App Store Connect API key — produces the APP_STORE_CONNECT_API_KEY_ID / APP_STORE_CONNECT_API_ISSUER_ID / APP_STORE_CONNECT_API_KEY_P8 triple.
  7. Create two App Store Connect app records at appstoreconnect.apple.com → Apps → + → New App:

    Record Platforms to tick Bundle ID Name
    iOS / iPadOS / visionOS app iOS ✓, visionOS ✓ com.cabalmail.Cabalmail Cabalmail
    macOS app macOS ✓ com.cabalmail.CabalmailMac Cabalmail Mac

    SKU can be anything (e.g. cabalmail-ios, cabalmail-mac); it’s never shown publicly. Primary language English (U.S.) or whichever fits.

    The “Cabalmail Mac” name is deliberate: App Store Connect requires each record’s name to be unique across your account, so the macOS app can’t reuse the iOS app’s “Cabalmail” listing. The mismatch is confined to App Store Connect and TestFlight metadata — the installed macOS app overrides CFBundleName and PRODUCT_NAME back to Cabalmail (see apple/project.yml), so the menu bar and the .app bundle on disk both read “Cabalmail”. Don’t try to rename the App Store Connect record to “Cabalmail” to “fix” the apparent inconsistency; Apple will reject the name as conflicting.

    Without these records, CI uploads land in App Store Connect but aren’t attached to anything visible and you cannot distribute the build.

  8. Populate the GitHub secrets listed in the next section.
  9. Create the TestFlight internal groups CI distributes to. On each app record (iOS and macOS), create two internal testing groups named exactly stage and prod (lowercase — the upload jobs look the group up by name):
    • App Store Connect → your app → TestFlight tab → Internal Testing → +.
    • Add your Apple ID (with an App Store Connect role) as a tester to the groups you want builds offered on.
    • Leave “Enable automatic distribution” unchecked in the creation dialog. CI attaches each uploaded build to the group matching its branch (stage pushes → stage, main → prod) via the App Store Connect API (assign-testflight-group.py), and fails the upload job if the attach doesn’t succeed. Automatic distribution would give every group every build, erasing the branch routing — and the API refuses explicit attaches to such groups, so the assign step would fail. The explicit attach exists to replace it, not to supplement it.
    • The distribution mode is immutable after creation — the checkbox exists only in the create dialog, and the group Settings tab merely displays the resulting “Build Distribution” state. To convert an existing automatic group: rename it aside (Settings → Edit Name), create a fresh group under the canonical name with the checkbox unchecked, re-add the testers, attach the latest build by hand so access continues, then delete the renamed group.
    • Install the TestFlight app on the target device, sign in, and accept the invite.

    Internal groups hold up to 100 team members and do not require Apple Beta Review. External groups (invite-by-email, up to 10,000 testers) would be the next step before an App Store launch.

GitHub secrets for CI

The test and build jobs in .github/workflows/apple.yml (kit-test, app-test, ios-app-test, app-build) run unsigned (CODE_SIGNING_ALLOWED=NO) and require no secrets — PRs from any branch get green CI out of the box.

The upload-ios and upload-mac jobs sign and push to TestFlight using manual code signing: no -allowProvisioningUpdates, no auto-creation of Development or Distribution certificates from the runner. The archive grabs the provisioning profile out of a pre-installed file identified by its UUID. One cost: you register the profile once and supply it as a secret. One benefit: Apple’s per-team certificate cap (2 Development / 3 Distribution) can’t fail the build the way it does under auto-provisioning.

The jobs are gated on the secrets below. upload-ios skips cleanly if any of its required secrets are absent, naming the specific missing secret(s) in the workflow summary. upload-mac fails in the same situation — missing Mac signing secrets on a main / stage push is treated as a release regression rather than an opt-in skip. Secrets may be set at the repository level or per-environment (Settings → Environments → stage / prod).

Required (both jobs):

Secret What it is Where to get it
APPLE_TEAM_ID 10-character Apple Developer team ID developer.apple.com → Membership details. If you belong to multiple teams, make sure you’re viewing the right one.
APPLE_DISTRIBUTION_CERT_P12 base64 of your Apple Distribution .p12 See Exporting the distribution certificate below
APPLE_DISTRIBUTION_CERT_PASSWORD Password you set when exporting the .p12 GitHub does not accept empty secrets, so the export password must be non-empty
APP_STORE_CONNECT_API_KEY_ID ~10-character key ID (e.g. ABC123DEF4) App Store Connect → Users and Access → Integrations → Keys
APP_STORE_CONNECT_API_ISSUER_ID UUID shown next to “Issuer ID” on the same page —
APP_STORE_CONNECT_API_KEY_P8 base64 of the .p8 key file See Creating the App Store Connect API key below

Required (iOS job only):

Secret What it is
IOS_APP_STORE_PROFILE base64 of the .mobileprovision for com.cabalmail.Cabalmail (App Store distribution). See Creating provisioning profiles below. One profile covers both the iOS and visionOS upload legs — modern App Store profiles list both platforms.
WATCHOS_APP_STORE_PROFILE base64 of the .mobileprovision for com.cabalmail.Cabalmail.watchkitapp (App Store distribution). The iOS archive embeds the watch app, so the iOS upload leg skips (with a warning) until this secret exists; the visionOS leg is unaffected.
IOS_NSE_APP_STORE_PROFILE base64 of the .mobileprovision for com.cabalmail.Cabalmail.NotificationService (App Store distribution), the push Notification Service Extension embedded in the iOS archive. Gates both the iOS and visionOS upload legs: the push entitlements live on the shared app target, so every leg’s archive needs profiles issued against the capability-bearing App ID — this secret doubles as the “push signing assets are ready” sentinel, and both legs skip (with a warning) until it exists.

Required (macOS job only):

Secret What it is
MAC_APP_STORE_PROFILE base64 of the .provisionprofile for com.cabalmail.CabalmailMac (App Store distribution).
MAC_NSE_APP_STORE_PROFILE base64 of the .provisionprofile for com.cabalmail.CabalmailMac.NotificationService (App Store distribution), the push Notification Service Extension embedded in the macOS archive. The macOS upload leg skips (with a warning) until this secret exists — it doubles as the “macOS push signing assets are ready” sentinel.
MAC_INSTALLER_CERT_P12 base64 of a Mac Installer Distribution .p12. The outer .pkg that wraps the macOS .app is signed with this cert (distinct from Apple Distribution, which signs the .app bundle itself). See Exporting the Mac Installer certificate.
MAC_INSTALLER_CERT_PASSWORD Password used when exporting the .p12. Must be non-empty.

Optional (macOS notarized .app artifact):

Secret What it is
DEVELOPER_ID_CERT_P12 base64 of your Developer ID Application .p12 (different cert type from Apple Distribution)
DEVELOPER_ID_CERT_PASSWORD Password you set when exporting the .p12
MAC_DEVID_PROFILE base64 of the .provisionprofile for com.cabalmail.CabalmailMac (Developer ID distribution). All three Developer-ID secrets (cert + this + the NSE profile below) must be set to produce the notarized artifact; missing any one and the job completes after the TestFlight upload and skips notarization.
MAC_NSE_DEVID_PROFILE base64 of the .provisionprofile for com.cabalmail.CabalmailMac.NotificationService (Developer ID distribution) — the embedded extension needs its own profile under the developer-id export method.

The App Store Connect API key triple (KEY_ID + ISSUER_ID + P8) is used for altool uploads and macOS notarytool submission. Under manual signing xcodebuild itself no longer needs the key at archive time, but the other callers still do.

See Creating provisioning profiles for the one-time setup and Optional: Developer ID certificate for notarized artifacts for the Developer ID flow.

Exporting the distribution certificate

You need an Apple Distribution certificate that belongs to the same team as APPLE_TEAM_ID. A development certificate or a certificate from a different team will not work for TestFlight.

  1. Open Keychain Access (⌘+Space → type Keychain Access, or Applications → Utilities → Keychain Access).
  2. In the sidebar, select the login keychain and the My Certificates category.
  3. Look for an entry named Apple Distribution: Your Name (TEAMID) where TEAMID matches APPLE_TEAM_ID.
    • If you only see Apple Development: … or iPhone Developer: …, or the TEAMID is wrong, you need to create a new one. In Xcode: Xcode → Settings → Accounts, select your Apple ID and the correct team, click Manage Certificates…, then + → Apple Distribution. The new cert lands in the login keychain automatically.
  4. Right-click the cert → Export “Apple Distribution…”, save as .p12.
  5. When prompted, enter a non-empty password you’ll remember (or openssl rand -base64 24 | pbcopy and keep it on the clipboard).
  6. Authenticate with your macOS login password when Keychain asks.
  7. Encode and copy:
    base64 -i ~/Desktop/cabalmail-dist.p12 | pbcopy
    

    Paste into APPLE_DISTRIBUTION_CERT_P12. Put the export password into APPLE_DISTRIBUTION_CERT_PASSWORD.

  8. Delete the .p12 when done — it contains your private key:
    rm ~/Desktop/cabalmail-dist.p12
    

Exporting the Mac Installer certificate

The Mac App Store wraps every .app in a .pkg, and Apple requires the outer .pkg to be signed with a separate Mac Installer Distribution certificate (a.k.a. 3rd Party Mac Developer Installer — Keychain and portal use different names for the same cert type). Distinct from the Apple Distribution cert used to sign the .app itself. Skip this section if you only ship iOS.

  1. Create the cert at developer.apple.com → Certificates → + → Mac Installer Distribution:
    • Generate a CSR in Keychain Access (Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority… → enter your email, pick Saved to disk, Continue).
    • Upload the CSR, download the returned .cer, double-click it so Keychain Access imports it and links it to the private key.
  2. Export from Keychain Access exactly like the Apple Distribution cert — right-click the cert (labelled 3rd Party Mac Developer Installer: Your Name (TEAMID) on modern macOS, or Mac Installer Distribution: … on older installs — functionally identical), Export “…”, save as .p12, non-empty password.
  3. Encode and set the secrets:
    base64 -i ~/Desktop/cabalmail-installer.p12 | pbcopy
    

    Paste into MAC_INSTALLER_CERT_P12. Password into MAC_INSTALLER_CERT_PASSWORD.

  4. Delete the .p12:
    rm ~/Desktop/cabalmail-installer.p12
    

Creating provisioning profiles

CI signs every archive with a provisioning profile you created ahead of time. Three profiles are needed at most — iOS App Store, macOS App Store, macOS Developer ID — and each lives in App Store Connect referencing the distribution cert you just exported. Recreate them whenever the cert rolls (typically once a year); otherwise nothing to do.

  1. Register the App Group and App IDs (one-time) at developer.apple.com → Identifiers → +, if you haven’t already — with the capabilities listed in step 4 of Signing prerequisites:
    • App Group group.com.cabalmail.Cabalmail
    • com.cabalmail.Cabalmail (App IDs → iOS, tvOS, watchOS, visionOS) — Push Notifications + App Groups + Associated Domains
    • com.cabalmail.Cabalmail.NotificationService (App IDs → iOS, tvOS, watchOS, visionOS) — App Groups only; the push Notification Service Extension embedded in the iOS archive
    • com.cabalmail.Cabalmail.watchkitapp (App IDs → iOS, tvOS, watchOS, visionOS) — the embedded watch companion app
    • com.cabalmail.CabalmailMac — Push Notifications + App Groups + Associated Domains
    • com.cabalmail.CabalmailMac.NotificationService — App Groups only; the push Notification Service Extension embedded in the macOS archive

    For the two Mac App IDs, create the profiles with scripts/make-mac-profile.py rather than the portal UI — the portal cannot produce a macOS-platform profile for a universal App ID (see the macOS profile trap in Signing prerequisites), e.g.:

    ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_KEY_P8=~/keys/AuthKey_....p8 \
    python3 scripts/make-mac-profile.py \
      com.cabalmail.CabalmailMac.NotificationService \
      "Cabalmail macOS NSE App Store"
    

    The script writes the .provisionprofile, prints the exact base64 for the GitHub secret, and names the security cms platform check to run first. API-minted profiles appear in the portal’s Profiles list afterwards and are manageable there like any other.

    Capabilities must be on the App ID before its profiles are created: editing an App ID’s capabilities flips every existing profile for it to Invalid, and each must then be re-issued (click the profile → Edit → Save/Generate → Download) and its GitHub secret refreshed. Profiles for other App IDs are unaffected.

  2. Create the profiles at developer.apple.com → Profiles → +:

    Profile Distribution type App ID Certificate Filename extension
    Cabalmail iOS App Store App Store com.cabalmail.Cabalmail Apple Distribution .mobileprovision
    Cabalmail NSE App Store App Store com.cabalmail.Cabalmail.NotificationService Apple Distribution .mobileprovision
    Cabalmail Watch App Store App Store com.cabalmail.Cabalmail.watchkitapp Apple Distribution .mobileprovision
    Cabalmail macOS App Store App Store com.cabalmail.CabalmailMac Apple Distribution .provisionprofile
    Cabalmail macOS NSE App Store App Store com.cabalmail.CabalmailMac.NotificationService Apple Distribution .provisionprofile
    Cabalmail macOS Developer ID (optional) Developer ID com.cabalmail.CabalmailMac Developer ID Application .provisionprofile
    Cabalmail macOS NSE Developer ID (optional) Developer ID com.cabalmail.CabalmailMac.NotificationService Developer ID Application .provisionprofile

    Profile names are arbitrary — CI matches by the UUID embedded in the file, not the name. All profiles share the same Apple Distribution certificate. Create the four macOS rows with scripts/make-mac-profile.py (pass MAC_APP_DIRECT as the third argument for the Developer ID variants), not the portal UI — see the macOS profile trap above.

  3. Download each profile (click the profile → Download; the script already wrote the mac ones locally and printed their base64).

  4. Base64-encode each and paste into the matching GitHub secret:
    base64 -i "Cabalmail_iOS_App_Store.mobileprovision" | tr -d '\n' | pbcopy
    

    | Downloaded file | GitHub secret | |—|—| | iOS .mobileprovision | IOS_APP_STORE_PROFILE | | NSE .mobileprovision | IOS_NSE_APP_STORE_PROFILE | | Watch .mobileprovision | WATCHOS_APP_STORE_PROFILE | | macOS App Store .provisionprofile | MAC_APP_STORE_PROFILE | | macOS NSE App Store .provisionprofile | MAC_NSE_APP_STORE_PROFILE | | macOS Developer ID .provisionprofile | MAC_DEVID_PROFILE | | macOS NSE Developer ID .provisionprofile | MAC_NSE_DEVID_PROFILE |

    Stray whitespace — a trailing newline from the paste, or a base64 build that wraps its output — is harmless: the workflow strips all whitespace before decoding, and the very next step fails loudly if the decoded bytes aren’t a valid signed profile.

  5. Delete the downloaded files — they embed the team’s distribution cert public key and the App ID’s capabilities, and can be re-created from the portal if you need them again.

Creating the App Store Connect API key

  1. App Store Connect → Users and Access → Integrations tab → Keys.
  2. Click the + to generate a new key.
  3. Name it something descriptive (e.g. Cabalmail CI). Role: App Manager. App Manager covers everything CI needs — TestFlight upload, notarization, and the profile-creation API that scripts/make-mac-profile.py calls to mint macOS profiles. Keep the downloaded .p8 somewhere durable (a password manager, not just the GitHub secret): Apple only lets you download it once, and you will need it locally again every time a macOS profile has to be re-minted — capability changes invalidate profiles, and the portal UI cannot recreate the macOS ones.
  4. Copy the Issuer ID (top of the page) → APP_STORE_CONNECT_API_ISSUER_ID.
  5. Copy the Key ID (shown in the row for the new key) → APP_STORE_CONNECT_API_KEY_ID.
  6. Click Download API Key. This is your only chance — if you close the page without downloading, you have to revoke the key and create a new one.
  7. Encode and copy. Use tr -d '\n' to strip the line wrapping macOS’s base64 adds at 76 chars; GitHub secrets can mangle whitespace in multiline values and CI’s decode step is stricter as a result:
    base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n' | pbcopy
    

    Paste into APP_STORE_CONNECT_API_KEY_P8.

  8. Delete the .p8 — it grants broad write access to your App Store Connect account:
    rm AuthKey_XXXXXXXXXX.p8
    

Optional: Developer ID certificate for notarized artifacts

Only needed if you want upload-mac to produce a notarized .app.zip workflow artifact for distribution outside the App Store / TestFlight. Without these secrets, upload-mac completes successfully after the TestFlight upload and skips the notarization steps.

  1. Xcode → Settings → Accounts → select your Apple ID and team → Manage Certificates… → + → Developer ID Application. (This is a different cert type from Apple Distribution — you need both.)
  2. Export the new certificate from Keychain Access the same way as the distribution cert (see Exporting the distribution certificate), set a non-empty password, encode with base64 -i cert.p12 | tr -d '\n' | pbcopy.
  3. Set DEVELOPER_ID_CERT_P12 and DEVELOPER_ID_CERT_PASSWORD.

CI workflow

.github/workflows/apple.yml runs on PRs that touch apple/** or its own CI files, on pushes to main and stage that do, and on workflow_dispatch. Its jobs:

Job Runs when What it does
promotion-verdict Pushes to main .github/scripts/apple-promotion-verdict.py: reports the push as already tested when it is a merge whose tree is the stage tip’s and the release PR’s run on that tip passed every test leg. Anything it cannot show, an API error included, reports untested
kit-test Every run but a main push reported tested SwiftLint and the platform-conditionals check (macOS leg only), then xcodebuild test on the CabalmailKit package (scheme CabalmailKit-Package) on macOS and iOS, and on visionOS too except on a PR into a branch other than main
app-test Same The CabalmailMacTests app-layer suite, hosted by CabalmailMac
ios-app-test Same The CabalmailiOSTests app-layer suite, hosted by the iOS app on a simulator
app-build PRs, and workflow_dispatch from a branch other than main or stage Unsigned xcodebuild build: CabalmailVision (visionOS) and CabalmailWatch (watchOS) on a PR, where the two test suites already build the iOS and Mac apps; all four schemes, adding Cabalmail (iOS) and CabalmailMac (macOS), from another branch. Skipped on main and stage runs, whose upload archives are the device builds
apple-gate Every run The one check that sums up the test and build jobs above
approval Every run but a PR’s The gate-* environment in front of the uploads, after the three test jobs pass, or straight after promotion-verdict on a main push it reports tested
upload-ios main and stage runs, with the iOS signing secrets configured Manual-signed archives of Cabalmail (iOS, embedding the watch app) and CabalmailVision (visionOS) → TestFlight upload → attach to the branch’s internal test group
upload-mac main and stage runs, with the Mac signing secrets configured Manual-signed App Store .pkg → TestFlight upload, plus (optional) a Developer ID export → notarytool submit --wait → stapler staple → uploaded as a workflow artifact → attach to the branch’s internal test group

Both upload jobs no-op, with a workflow warning, when their required secrets are missing. Because app-build skips main and stage runs, such a run then builds nothing for a device. Build and test jobs never require secrets.

Manual signing installs a pre-created provisioning profile from a GitHub secret via .github/actions/install-provisioning-profile (a small composite action) and passes the profile’s UUID to xcodebuild as PROVISIONING_PROFILE_SPECIFIER. No -allowProvisioningUpdates, no auto-provisioning, no Apple Development cert creation from the runner. See the GitHub secrets for CI section above for how to supply each profile.

Pinned Xcode version lives in the XCODE_VERSION env var at the top of the workflow; bump it in lockstep with the deployment targets in project.yml and CabalmailKit/Package.swift.

Installing a build from TestFlight

After a successful CI upload and App Store Connect processing (5–30 min), the build appears in your app’s Builds list, and the upload job attaches it to the internal group matching the branch it was built from (stage pushes → the stage group, main → prod) — see the group setup in Apple Developer account setup. To install one:

  1. On your device, install the TestFlight app from the App Store.
  2. Sign in with the Apple ID that is a member of the group and accept the invite from the TestFlight inbox. The build installs like any App Store app, with a small yellow dot marking it as a beta.

macOS follows the same flow using the macOS TestFlight app (install from the Mac App Store). If a build you expected never shows up, check the upload job’s Assign build to TestFlight group step — it fails (with the build number in the error) when the build could not be attached, and the fallback is attaching that build by hand from the group’s Builds tab.

Setting Cabalmail as the default mail handler

Cabalmail registers as a mailto: handler on both iOS and macOS, but selecting it as the system default is a one-time user action — the OS does not let an app elect itself.

macOS works out of the box. Register the scheme (already done by CFBundleURLTypes in project.yml) and the app shows up in System Settings → Desktop & Dock → Default mail reader; pick Cabalmail and mailto: clicks across the system route here.

iOS / iPadOS gates default-mail-app candidacy behind the com.apple.developer.mail-client entitlement, which Apple approves case-by-case. Until the entitlement is granted, the app will not appear in Settings → Apps → Mail → Default Mail App, even though CFBundleURLTypes is registered. To enable it:

  1. Submit the default-app entitlement request via Apple’s web form at https://developer.apple.com/contact/request/default-mail-client (the form replaced the older default-app-requests@apple.com address). The form asks the submitter to confirm, among other things, that:
    • the app specifies the mailto: scheme in its Info.plist,
    • the app can send a message to any valid email recipient,
    • invoking the mailto: handler opens a new compose view with the To: address set to the target of the URL,
    • the app can receive a message from any email sender.

    All four are true of Cabalmail today; the wiring is in place and the unit tests in CabalmailKit/Tests/CabalmailKitTests/MailtoURLTests.swift cover the parser. Apple reviews and grants the entitlement against your team.

  2. After approval, enable the Default Mail App capability on the com.cabalmail.Cabalmail App ID (Identifiers → the App ID → Capabilities). Editing the capabilities flips the App ID’s existing profiles to Invalid, so re-issue the iOS App Store profile and refresh the IOS_APP_STORE_PROFILE secret — see Creating provisioning profiles.
  3. apple/Cabalmail/Cabalmail.entitlements (wired into the iOS target’s CODE_SIGN_ENTITLEMENTS via project.yml) carries the matching key:

    <key>com.apple.developer.mail-client</key>
    <true/>
    

    Signed archives fail while the provisioning profile lacks the entitlement, so a fork must remove this key until its own request is approved and the profile regenerated.

  4. Ship a new TestFlight build against the updated profile.

Apple’s rules forbid combining com.apple.developer.mail-client with com.apple.developer.web-browser in the same app — pick one.

Once the entitlement lands and the user picks Cabalmail in Settings, mailto: clicks in Safari and other apps open Cabalmail with a compose window pre-filled from the URL’s recipients, subject, and body. Only the standard RFC 6068 hfields (to, cc, bcc, subject, body) are honored; other headers are dropped.

Default-app request: cover-letter template

The web form’s free-text box asks for “additional information and test credentials to confirm that your app meets the mail client criteria.” Before submitting, provision a fresh Cabalmail account on the deployment the TestFlight build points at, then paste the text below into the form with the bracketed placeholders filled in. Rotate the password (or delete the account) after Apple completes review.

Cabalmail is a self-hosted native email system for iOS, iPadOS,
visionOS, and macOS. The app is a real mail client: composes traverse
open-Internet SMTP via the operator's own SMTP-OUT relay with DKIM
signing, and inbound mail is delivered through standard SMTP-IN +
IMAP. There is no proprietary transport. Source code, including the
mailto: handler and parser, is public at
https://github.com/cabalmail/cabal-infra (see apple/CabalmailUI/
Shell/AppRootLifecycle.swift and apple/CabalmailKit/Sources/
CabalmailKit/Compose/MailtoURL.swift).

Test credentials for the deployment this TestFlight build is built
against:

  Control domain:  [example.cabalmail.com]
  Username:        [apple-review]
  Password:        [<one-time-password>]
  Test address:    [apple-review@mail.example.cabalmail.com]

The build prompts for the control domain on first launch. Sign in
with the credentials above; the message list opens to the test
account's Inbox.

Verifying each criterion:

1. mailto: in Info.plist. The shipped IPA's Info.plist contains
   CFBundleURLTypes with scheme "mailto" and role "Editor". This is
   generated from apple/project.yml.

2. Sends to any valid recipient. From the message list, tap the
   compose button. Pick a From address from the picker (an initial
   address is auto-provisioned at signup; "Create new address..."
   makes more). Enter any external email address in To, then send.
   Delivery to Gmail, iCloud, and Outlook has been verified in
   production.

3. mailto: handler opens compose with To: pre-filled. The
   .onOpenURL handler parses incoming URLs with the RFC 6068 parser
   covered by MailtoURLTests.swift and routes the result to compose.
   The macOS sibling target shares the same wiring and has been
   verified end-to-end — clicking mailto:test@example.com?subject=Hi
   &body=Hello in Safari opens compose with all three fields
   pre-filled. iOS uses the same SwiftUI .onOpenURL modifier on the
   same handler.

4. Receives mail from any sender. Send a test message from any
   external account to the test address above; it lands in the
   account's Inbox within seconds. The SMTP-IN tier applies spam
   filtering but no sender allowlist.

References:

App icons

Real Cabalmail artwork is installed for every target, all of it generated from the single source vector at vector/cabalmail-logo.svg by scripts/generate-logo-assets. Nothing is hand-edited — edit the vector and regenerate. See vector/README.md for the full spec (geometry, color tokens, placement transform, per-platform do-not list).

apple/Cabalmail/AppIcon.icon/           (iOS / iPadOS — Liquid Glass)
apple/CabalmailMac/AppIcon.icon/        (macOS — Liquid Glass)
  icon.json                Default = forest glyph on cream, Dark = parchment
                           glyph on ink; one ink-glyph template recolored per
                           appearance, on a specialized background gradient.
  Assets/Mark.svg          the glyph template.
apple/Cabalmail/Assets.xcassets/AppIconVision.solidimagestack/
  Back / Middle / Front layers  (1024×1024; opaque cream plate, forest
                           C-disc, forest-deep M — composited with parallax)
apple/CabalmailWatch/Assets.xcassets/AppIcon.appiconset/
  AppIcon-watch.png        (single 1024 slot; watchOS masks it itself)

The square icon (iOS / iPadOS / macOS) is an Icon Composer .icon bundle, not an asset-catalog appiconset. This is the only format that carries a per-appearance macOS icon: an asset catalog’s luminosity appearances are silently dropped by actool for the mac idiom (they compile to a legacy, appearance-blind .icns), which is why the macOS icon used to ignore the Dark setting. actool still emits the older prerendered fallbacks from the same .icon (.icns on macOS, AppIcon60x60/76x76 on iOS), so the macOS-15 / iOS-18 deployment floors keep a valid icon while 26+ gets the Liquid Glass rendering with Default/Dark (and system-synthesized Tinted/Clear). Layer-level fill-specializations are ignored by the renderer, so the bespoke dark glyph is expressed as two overlaid glyph layers (forest on top, hidden in Dark; parchment beneath) rather than one recolored layer.

All icons pass xcrun actool cleanly, and the three build destinations (iphoneos, macosx, xros) archive with the correct primary icon.

Regenerating

make logo            # or: ./scripts/generate-logo-assets

One command regenerates every derivative (both Apple catalogs, the React client, the docs images, the front-door favicon) idempotently. The generator uses a version-pinned resvg + oxipng, so output is byte-identical across macOS and CI. Do not apply a squircle mask, bake a drop-shadow, or grayscale the tinted variant — iOS / macOS / visionOS each own those at runtime.

visionOS uses the layered AppIconVision.solidimagestack

The CabalmailVision target is served by a layered AppIconVision.solidimagestack (back / middle / front layers, composited with parallax on gaze focus), named on that target in project.yml:

ASSETCATALOG_COMPILER_APPICON_NAME: AppIconVision

iOS / iPadOS and macOS use the shared AppIcon.icon instead; visionOS is the one square-icon holdout on the layered stack. The back plate is a fully-opaque 1024 bitmap (actool rejects a transparent back layer); the system applies the circular mask. Validating the parallax depth effect still requires a visionOS device in the loop.

Architecture

macOS: native target (not Mac Catalyst)

The roadmap treats macOS as a first-class platform, so the macOS target is native rather than Mac Catalyst. CabalmailMac/ is a separate app target with its own @main, menu commands, windows, settings, asset catalog and entitlements. Everything it shares with the iOS app comes from the CabalmailUI module (below). Where the platforms diverge there, the difference lives in a layout shell or a platform adapter (see “Platform conditionals”).

visionOS is its own target

Cabalmail builds for iPhone and iPad; CabalmailVision builds the same apple/Cabalmail/ sources for visionOS. One target served both until #1865: because a visionOS product can embed neither the watch app nor the iOS app extensions, those three dependencies had to be destination-filtered to iOS — and an embed phase that exists with every member filtered out makes the CodeSign task take the app wrapper directory as an input. The second signed xrsimulator build into one DerivedData then fails with error: Cycle inside Cabalmail, and the directory never recovers (only a rm -rf does). Splitting the platforms removes the filtered phase: the embeds are unconditional on Cabalmail and absent from CabalmailVision.

The two targets are one App Store app — same PRODUCT_BUNDLE_IDENTIFIER, same entitlements file, same App Store provisioning profile (modern profiles list iOS and xrOS together) — and VisionAppTargetScanTests holds that, the absent embeds, and apple.yml’s visionOS legs to the shape above. What differs is only what the platform forces: the layered app icon, the device family, and a separate generated Info.plist (Cabalmail/InfoVision.plist, which carries no launch screen or orientation list). Each target excludes the other’s generated plist from the shared source folder; XcodeGen auto-excludes only a target’s own.

Shared app layer: the CabalmailUI module

apple/CabalmailUI/ holds the app layer the iOS / iPadOS / visionOS and macOS apps have in common: every view, view model and piece of app state. It is a static library target in apple/project.yml, compiled once per platform as the Swift module CabalmailUI, and it inherits the project’s SWIFT_STRICT_CONCURRENCY: complete. Membership is the folder: a new file anywhere under CabalmailUI/ is in both apps with no project.yml edit.

What stays in the app targets is what only one platform has: Cabalmail/ keeps the iOS @main (CabalmailApp.swift), the App Intents (Siri and Shortcuts read intent metadata from the app’s own binary, so they cannot live in a library), the Info.plist, the entitlements and the asset catalogs; CabalmailMac/ keeps the macOS @main, the menu bar, the Settings window and the menu-bar extra. The shared session lifecycle reaches the App Intents through AppIntentsSessionHooks, which CabalmailApp installs at launch.

Rules that keep the module working:

The module is sorted into feature folders, at most two levels deep. Loose files in a feature folder are shared by that feature’s subfolders.

Folder What lives there
App/ AppState and all of its extension files, and the app-level types it holds: the toast, the signed-out reason, the drag-and-drop move request. AppState holds the compose coordinator and slot registry, drag and drop, contacts, BIMI and mailStore, and what the app does once as it leaves and returns to the foreground (appScenePhaseChanged(to:), called by each app entry’s scene-level handler); its session surface (status, client, navCoordinator, sign-in and sign-out) forwards to its SessionManager
Session/ The session lifecycle, in SessionManager: sign-in and its second factor, restoring the last session, signing out and its ordering (SessionTeardownGate), the session’s client, cursor, preferences sync and expiry observer, the Inbox badge and feed pollers (SessionPollers), and lending the client to the push and App Intents paths (borrowClient(), one client per account). SessionEnvironment and SessionHooks are its seams to the outside; SessionOwnerHooks are what a session does to AppState’s state. Also the sign-in screen and its error wording
Navigation/ Each main window’s SceneNavigator (its AppRoute, landing, search model, and the hand-off to a tree a layout swap rebuilds), with the feed reader’s FeedNavigationState and the TreeGate both use, the window’s WindowRestores (the message, list place, reading position and feed item its lists and reader take once ready), its FolderListHold (the folder list’s selection, which a layout swap hands on, multi-selection included, and where that list is scrolled, as a ListAnchor: its top row by Message-ID, UID and position), its StoredRoute (the route its scene storage keeps, and where its folder list was scrolled, for the account they were stored for) and its WindowRecorder (only the window last used records the place the app resumes from); NavStateCoordinator (the per-install resume session, which also keeps where the last-used window’s folder list was scrolled, on this device only, reading positions and the cross-device cursor); DeepLinkRouter (a notification, a Spotlight result or Siri’s Open Folder opens in one main window: the system’s target, else the window last used) with the WindowRegistry it finds windows in, and Spotlight routing
Commands/ The Message, Mailbox and Feeds menu commands, when each is enabled, and which window it acts on
Shell/ How a window is laid out. The sign-in / signed-in router resolves the window’s ShellLayout and draws one of four layout shells: DesktopShell (the Mac window), SplitShell (the iPad split, and iPhone Duo unfolded), TabShell (the iPhone tab bar, a narrowed iPad window and Duo folded) and OrnamentShell (the Vision Pro tabs). WideMail is what the two wide shells share, CompactTab is the tab list as data, and CompactMailStack is the phone’s Mail tab. Also here: the layout and column policies, the split’s Settings sheet presenter, the per-window theme, and the main window root’s launch and lifecycle chain both app entries apply (appRootLifecycle)
Shell/Columns/ What the mail columns show on every layout, with no platform conditionals (MailSidebarColumn, MailContentColumn, MailReaderColumn, FeedReaderColumn); column and inspector widths, the column resize handle, the macOS split-view autosave workaround
Mail/ Mail pieces used by more than one mail column: drag and drop, Move to Folder, the sender avatar, the authentication line
Mail/Store/ Mail state the folder list, message list, reader and composer share: MailSessionStore, which AppState owns as mailStore and resets at sign-out, made of MailCounts (folder counts: the unread ones are the sidebar badges and a message list’s Unread pill, the flagged ones its Flagged pill; and the Inbox count behind the app badge), MessageShields (the one record of writes in flight and removals just confirmed, which every list’s merge and every writer of a fetched STATUS asks, so a refresh can’t undo a write made anywhere or count it twice), MailEvents (every change to mail, delivered at once and in order to every message list’s and reader’s view model but the one that made it, naming the window that started it when a reader did, and saying whether another list’s selection may move on), and MailMutationService (the one place the app’s writes go through: every flag change, move, dispose and purge from a list, the reader or the composer is recorded, posted and counted before it goes out, made through the writer’s client, then confirmed, forgetting a removed message in the offline caches, or taken back; Mark All as Read and Empty Trash go through it too, acting once the server answers; a notification’s actions are the exception, #1973); and FolderPollers (the change watching of the folders open in message lists: one FolderPoller per folder, made by the first list showing it and stopped with the last or at sign-out, whose watcher events and 60-second tick each ask one STATUS that every list on the folder takes); also saved folder counts
Mail/Folders/ The folder sidebar and its view model, filters, rows, New Folder
Mail/MessageList/ MessageListView, MessageListViewModel and their extension files: rows, swipes, selection, bulk actions, sort, the folder-switch menu; FolderWindowLoader, a folder list’s window (rows, positions, paging, refresh; the search surface has none), with its engines WindowPager, WindowRefresher, WindowReconciler and WindowSnapshot, and the value types EnvelopeOrder and WindowPlanner; ListPlaceTracker, which records a folder list’s top row for its window as it scrolls and reopens the list there after a layout swap or a relaunch
Mail/Reader/ MessageDetailView, MessageDetailViewModel and their extension files: the header, the toolbar and its policies, attachments, calendar invites, View Source
Mail/Search/ MailSearchSession, a list’s search (query, filters, results and their paging), the search model and query, the Search tab, the global search field, the filters sheet, and the list’s +Search extension files
Feeds/ The Feeds tab root, FeedStoreChanges (how the feed sidebar, item list and reader follow RssStore.changes(), each from its view’s .task through its model’s observe()), feed health and per-feed web storage
Feeds/Sidebar/, Feeds/ItemList/, Feeds/Reader/, Feeds/Management/ The feed tree, the item list, the item reader, and subscribing, editing and OPML
Compose/ ComposeView, ComposeViewModel, the From picker, drafts and the failed-send banner
Compose/Recipients/, Compose/Editor/, Compose/Windows/ The To / Cc / Bcc fields and contacts picker; the rich-text editor; how a composer opens and closes (ComposeCoordinator, which sends each compose request to one main window’s compose surface and keeps the ones a window cannot show yet; that surface, ComposeRequestRouter; the slot registry; the scene), and on iPad each main window’s scene session, so closing a composer returns to the main window it came from and a notification finds the window it was shown over
Addresses/ The address list, its view model, New Address, address titles in menus
Rules/ The rule list, the rule editor and its view model
Admin/ Administrator screens, listed in Settings only when the session’s token names the admin group (SessionManager.isAdmin): the Users list, each account’s page (confirm, enable or disable, delete, domain access) and UsersViewModel. The server refuses every admin call to anyone else; a 403 shows as “Administrators only”
Settings/ The Settings screens, the iPad Settings sheet, preference sync
Shared/Chrome/ Feature-neutral chrome: the filter pill every pill row draws (FilterPill, and FilterPillStrip, which stacks pills in a narrow column), the sidebar count badge (CountBadge, which draws FolderCountBadge’s rule on folder and feed rows alike), the sidebar tree row (SidebarTreeRowLabel, which indents, discloses and tints mail folders and feeds by FolderIconTint and FolderNameTint), sidebar header and filter rows, the list title menus’ shared rows and hosts (TitleSwitchMenu.swift), toolbar priority, the reader toolbar’s budgets and placement for both readers (ReaderToolbarPolicy; each reader’s action lists stay with it, in ReaderToolbarLayout and FeedReaderToolbarLayout), the faces of the toggles both readers share (ReaderToggleFace: read, flag, reader view, remote content, and mail’s plain text, each glyph naming the action its label names), branding
Shared/Primitives/ Generic building blocks: the load-state scaffold, the flow layout, a list’s selection (SelectionModel: the rows picked, Select mode, and the anchor and cursor a range selection works from)
Shared/BodyRendering/ Rendering a message or article body for both readers: the HTML view and its bridges, HTML rewriting, plain text, the link menu
Shared/Banners/ Toasts and where banners sit
Platform/ Small per-OS adapters: host platform and its capabilities, confirmation-dialog roles, the pasteboard, and the one-line modifiers a feature view calls in place of a platform conditional (navigation-bar title and Edit button, text entry, list and form styles)
Platform/Services/ Push (the app delegate and PushRegistrar) and the watch hand-off

A file belongs in Shared/ only if it knows nothing about any one feature, or if several features use it without carrying one feature’s logic; otherwise it stays with its feature.

Platform conditionals

A view that differs by platform does not write #if os(...). The difference goes in one of two places:

scripts/check-platform-conditionals.py holds the line in CI, beside SwiftLint in apple.yml’s kit-test macOS leg, and on its own in lint.yml’s platform-conditionals job. In every file under apple/CabalmailUI/ outside Shell/ and Platform/, the wrapper files aside, it counts the lines that start with #if and test os(...) or canImport of UIKit, AppKit or EventKitUI. The files that still hold such conditionals are listed, with their counts, in apple/platform-conditionals-allowlist.txt. A file that is not listed fails, and so does a count above its row, so the list only shrinks. The change that removes a conditional lowers or removes the row in the same PR (--seed prints the rows the tree needs). A row left above its file’s count does not fail, since two PRs that each lower one row merge cleanly, but the check reports it, as a warning on the PR, because it is room the next conditional could hide in: take it out when you see it.

Never add a row or raise a count to get a new conditional through. The one case that moves a count is moving the code: a renamed file takes its row with it, and code extracted into a new file takes its conditionals’ share of the old row, so the total the check prints (N files, M blocks) does not rise.

The check reads one line at a time, so two things are left to review: an #elseif os(...) arm added to an existing block, and a new conditional in a file that is exempt because it wraps a UIKit or AppKit view.

The script is standard-library Python and runs anywhere (scripts/build-apple.sh lint runs it after SwiftLint):

python3 scripts/check-platform-conditionals.py

Extension-shared values: the CabalmailShared module

The app extensions don’t link CabalmailKit, which keeps them small and keeps the Kit’s resource bundle out of them. What an extension and the app must spell identically lives instead in CabalmailShared, a second library product of the CabalmailKit package (apple/CabalmailKit/Sources/CabalmailShared/):

How it is linked:

Runtime configuration: published config.json

The React app loads runtime configuration from /config.js on CloudFront. config.js’s body happens to be valid JSON, so Terraform also writes a sibling config.json object from the same template variables (see terraform/infra/modules/app/s3.tf).

The Apple client fetches https://{control_domain}/config.json on first launch and caches it in UserDefaults. The same IPA works against dev/stage/prod by pointing at a different control domain — only the bootstrap URL differs. The schema is modelled by CabalmailKit.Configuration.

Cognito: hand-rolled USER_PASSWORD_AUTH

The Cognito pool is provisioned with explicit_auth_flows = ["USER_PASSWORD_AUTH"] (see terraform/infra/modules/user_pool/main.tf), so the wire surface reduces to JSON POSTs against https://cognito-idp.<region>.amazonaws.com/. CognitoAuthService drives that directly — no AWS SDK dependency, no ~2 MB extra binary. The AuthService protocol leaves room to swap in Amplify later.

Mail traffic: the Lambda API, not IMAP or SMTP

CabalmailKit speaks no mail protocol. ApiBackedImapClient adapts the same Lambda endpoints the React app uses (/list_folders, /list_envelopes, /fetch_message, /set_flag, /move_messages, …) onto the ImapClient protocol, and CabalmailClient.send(_:) posts to /send, which does the Outbox append, SMTP submission and Sent move server-side. Issue #371 made the switch after the earlier hand-rolled NWConnection IMAP and SMTP clients proved unreliable across network transitions and sleep/wake; that stack has since been deleted.

API errors: what a failed request throws

Every Kit API request throws CabalmailError, with one deliberate exception: a lost /set_rules race throws RuleSetConflictError. (A Cognito 2xx that isn’t JSON still escapes as a Foundation error, #1902.) The user-facing copy for every case is the enum’s LocalizedError conformance in Models/Errors.swift. Most views show error.localizedDescription; some word particular cases themselves, among them the sign-in form (SignInErrorText), the sign-up screens (SignUpErrorText), the composer (ComposeViewModel.describe), Siri (IntentError), the feed views (FeedErrorText) and the message list’s bulk actions.

A first send (CabalmailClient.send(_:)) treats .network, .transport, .cancelled, .sendInFlight and .storage as “queue the message in the outbox and retry” (CabalmailClient.shouldQueue). Any other error is thrown to the composer, which stays open and shows it. A message already in the outbox is retried by SendQueue, whatever the error, until Outbox.maxAttempts (a .sendInFlight answer doesn’t spend an attempt), then marked failed and offered back to the user (FailedSendBanner). A queued message the server accepted is not sent again by the queue that sent it, even if its outbox file can’t be deleted: that queue logs the failure once and retries only the deletion, Backoff.base apart. The guard lives in that queue’s memory, so a new queue over the same outbox (after a relaunch, for one) sends such a file again, and the server’s 600-second Message-ID dedupe is what keeps a prompt resend from delivering a second copy.

Storage: Keychain for secrets, on-disk Codable for mirrors

New-mail polling: AsyncThrowingStream over folder status

There is no IDLE. ApiBackedImapClient.idle(folder:) polls folder status and yields an IdleEvent when UIDNEXT advances or the message count drops; MailboxWatcher turns those events into refresh ticks and applies the reconnect backoff, and the folder’s poller (FolderPoller) coalesces bursts of ticks into one STATUS for every list showing the folder. Terminating the stream cancels the polling task.

Rich-text editor: WKWebView contenteditable + fetched marked/turndown

ComposeView ships a dual-mode body — segmented “Rich Text” / “Markdown” tabs — to match the React composer feature-for-feature. The rich pane is a contenteditable <div> inside a WKWebView, driven by a SwiftUI toolbar (RichTextToolbar) that calls document.execCommand through RichTextEditorController’s JS bridge. The markdown pane is a plain TextEditor; drafts persist as Markdown either way (the rich pane is re-seeded from the markdown source on open).

At send time, ComposeViewModel.computeMessageBodies() runs the same four-way table the React handleSend applies: both-empty, rich-only (text body derived via turndown), markdown-only (html body derived via marked + flattenParagraphs + styleParagraphs), or both-filled. So every outgoing message ships with both MIME parts populated and no recipient sees a blank message because their mail client preferred text/html.

The editor page runs its own script, so markup that comes into it is treated as somebody else’s. setHTML (the reply, forward and mailto seeds, a reopened draft, Import from Markdown) and an HTML paste or drop parse it in an inert <template> and keep only formatting elements and attributes (“inbound HTML” in editor-bridge.js). The page’s content security policy (editor.html) refuses inline script and requests made from script, and the controller lets the web view load the editor page once and go nowhere after that. In a reply or forward, setHTML also turns an image that would load from the network into its alt text or address and drops inline style, which can fetch too (setHTML(_:blockingRemoteImages:keepingImagesFrom:)), so quoting a message fetches nothing from its sender’s servers. The images in the user’s own signature stay, and a new compose, a mailto, a reopened draft and a paste keep theirs.

Why a WKWebView instead of native NSTextView / UITextView

Native rich-text editing on Apple platforms means NSAttributedString, and the .data(from: ..., documentAttributes: [.documentType: .html]) round-trip emits HTML with heavy inline-styled spans that doesn’t visually match what the React composer produces. Matching React’s specific rules (Enter as hard-break in plain paragraphs but new-list- item inside lists, blank-line paragraph boundaries collapsed to <br><br>, ZWSP placeholder trick to defeat turndown’s adjacent- newline collapsing) by hand against NSAttributedString is a much larger surface than letting the same JS libraries run inside a contenteditable.

Why marked + turndown are fetched, not committed

apple/CabalmailKit/Sources/CabalmailKit/Compose/WebAssets/ is the SwiftPM resource directory editor.html looks up its sibling scripts from. editor.html, error-relay.js and editor-bridge.js are first-party and committed; marked.umd.js, turndown.js, and their MIT LICENSE files are gitignored and materialize at build time from react/admin/node_modules/ via apple/scripts/sync-vendored.sh.

The version pins live in react/admin/package.json. The React composer already lists these exact libraries as runtime dependencies, so we get three useful properties for free by making React’s manifest the single source of truth:

We considered the obvious alternative — committing the JS verbatim into the resource directory with a CI drift-check that diffs against react/admin/node_modules/ after npm ci. That works, but it requires exactly the same npm ci step in CI, just to verify what we could have produced instead. The fetched-not-committed design pays the same CI cost and produces a strictly cleaner repo (no committed upstream bytes, no manual sync flow on version bumps).

The one cost we explicitly accept: a fresh clone can’t swift test the kit before running apple/scripts/sync-vendored.sh. The error is self-explanatory (SwiftPM names the missing resource) and the script is a one-liner; the Bootstrap section covers it.

A root-level vendor/ directory was also considered. SwiftPM requires resources to live inside the target’s path:, so a root vendor would need symlinks (Resources/marked.umd.js -> ../../../../../../vendor/...), and the kit would stop being self-contained. With these particular libraries now sourced from npm via the React manifest, the symlink convention has even less to recommend it. Revisit if a non-JS, non-npm-managed vendored dep ever appears.

Drafts: local buffer + cross-device sync

CabalmailKit.DraftStore persists drafts as Codable JSON under the app support directory, keyed by UUID. ComposeViewModel autosaves every 5 s while the sheet is open, so a mid-compose app kill is recoverable — this local copy is the live editing buffer and the crash-recovery story. A local save that fails is logged when it starts failing and when it recovers, not on every tick (DraftStore.autosave(_:)); the composer shows nothing, since the server copy keeps saving on its own schedule.

Drafts also sync across devices through the /save_draft Lambda, which owns a server-side lifecycle on the top-level Drafts mailbox. Server saves fire on compose close-without-send (always) and on a 60-second debounce while composing (skipped while the body is empty, while a send is in flight, or while another server save is running). The sync is last-writer-wins keyed on the returned (uidvalidity, uid): each save passes the prior copy’s coordinates as replaces_*, so the Lambda appends the new copy before expunging the old under a UIDVALIDITY guard whose worst-case failure is a duplicate draft, never a lost one. Opening a message in Drafts offers Edit Draft, reseeding compose from the fetched copy — recipients and subject from the envelope, Bcc and threading from the headers, body from the Markdown text part. Because both first-party composers are Markdown-canonical the round trip is lossless. See docs/draft-sync-and-threading.md.

Sent-folder copy: server-side via /send

Neither mail tier auto-APPENDs to Sent. The /send Lambda owns the whole outbound shuffle — Outbox APPEND, SMTP submission, and the Sent copy (staged to S3 and written by a decoupled queue consumer, see lambda/api/append_sent) — so CabalmailClient.send(_:) posts the compose payload and does no client-side APPEND. Sending from a synced draft passes the draft’s discard_draft_* coordinates so the server best-effort expunges the Drafts copy once delivery succeeds.

Compose scene

Preferences storage: account-scoped UserDefaults + server sync

CabalmailKit.Preferences persists through a pluggable PreferenceStore protocol. Production uses UserDefaultsPreferenceStore — plain local UserDefaults, nothing else; settings never touch iCloud. Storage is scoped per Cabalmail account: Preferences.activate(controlDomain: username:) runs on sign-in / restore and switches every read and write to that account’s own keys (the shared key plus a stable hash of domain + username), so two accounts on one device can never see or overwrite each other’s settings. Cross-device sync rides the Cabalmail account instead: PreferencesSyncCoordinator stores the settings per Cognito user on the server (/get_preferences / /set_preferences), pulls with server-wins semantics on login and foreground, and pushes debounced local edits.

A single @Observable class is used in preference to @AppStorage property wrappers because multiple views (compose, message detail, message list) need to read the same preference on the same code path, and the SwiftUI 18 @Observable macro makes sharing a Preferences instance across the environment cheaper than keeping a property wrapper in each view.

Signed-in navigation: TabView(.sidebarAdaptable)

SignedInRootView uses SwiftUI 18’s TabView(selection:) + the .sidebarAdaptable style so the same screen renders as a bottom tab bar on iPhone and as a collapsible sidebar on iPad / visionOS / macOS. macOS hides the Settings tab via a #if !os(macOS) guard because the Settings scene wired to ⌘, in CabalmailMacApp already covers that ground.

Signature insertion: RFC 3676 delimiter, static helper

Signatures are inserted via CabalmailKit.SignatureFormatter.seedBody, a pure value-type helper that prepends "\n-- \n<signature>" to the seed body. The RFC 3676 "-- " (dash-dash-space) on its own line is the canonical signature marker every UNIX mail client since Pine recognises, so downstream clients can collapse / strip the block when threading a long reply chain. Keeping the helper pure (and outside ComposeViewModel) lets SignatureFormatterTests pin the three entry-point layouts — empty-new-message, reply/forward-with-quoted-original, and arbitrary base — without spinning up the full view model.

Settings surface

Change watching is tied to the message list lifetime

MailboxWatcher consumes ApiBackedImapClient.idle(folder:), which polls folder status (see “New-mail polling” above), and emits .changed / .reconnecting / .active ticks on an AsyncStream. Each folder open in a message list has one FolderPoller (MailSessionStore.folderPollers), which owns the folder’s watcher and a 60-second tick however many windows show the folder. MessageListViewModel.startWatching(), from the list’s .task { }, puts the list on its folder’s poller, making the poller if the list is the first; stopWatching(), on .onDisappear, takes it off, and the last list off stops the poller. Sign-out (MailSessionStore.forgetAccount()) stops every poller. The watcher stays off while no list shows its folder — mailbox management, compose sheet, settings — so only the mailboxes on screen are polled. When the polling stream ends or fails, the watcher reopens it after a backoff that doubles from 2s to 60s while reopening keeps failing. idle(folder:) makes its first poll before it returns the stream, so an unreachable API fails the reopen itself and the backoff grows.

Each .changed tick asks one flagged STATUS of the folder, and so does the 60-second tick, which catches what the status poll can’t see: read and flag changes made elsewhere, and whatever arrives while the watcher backs off. A change within a second of the last one that polled is covered by it, so a message sweep doesn’t trigger N envelope fetches, and one poll runs at a time. Before the STATUS goes out, every list on the folder numbers a refresh ask and holds its spinner, as its own refresh would; each then takes the answer through refresh(prefetched:), so a list with a pill or search showing re-runs it, and a list whose own refresh is in flight waits for it. A failed STATUS shows on a folder list as its own refresh’s failure would, and re-runs a pill’s search.

Send failures classify transient vs permanent before queueing

CabalmailClient.send(_:) returns SendOutcome.sent or .queued. Transport / network errors (CabalmailError.network, connection timeouts), a cancelled request, the API’s duplicate-in-flight answer (.sendInFlight) and a keychain that can’t be read for the token (.storage) queue the OutgoingMessage into the on-disk Outbox and surface a warning toast (CabalmailClient.shouldQueue); application-level rejections (auth failure, malformed recipient, permanent SMTP 5xx) throw immediately so the compose sheet can correct them. SendQueue drains the outbox when NWPathMonitor reports reachability or on an explicit user kick, with maxAttempts = 10 before an entry is marked failed and kept for the user, so a permanently bad recipient can’t spin forever. One JSON file per entry under app support, same layout as DraftStore.

MetricKit is opt-in

Preferences.crashReportingEnabled defaults to false. When the user toggles it on in Settings → Diagnostics, CabalmailClient.setCrashReportingEnabled(true) subscribes the MetricKitCollector to MXMetricManager.shared and payloads land in DebugLogStore at the .info level. Off by default respects the “self-hosted email, minimum phoning-home” stance the project leans on; the toggle is explicit and the surface for viewing what’s captured is right there (Settings → Debug Log → ShareLink). visionOS doesn’t vend MetricKit at all, so the collector is a no-op on that platform behind #if canImport(MetricKit) && !os(visionOS).

Menu commands need to reach the view that owns the action, but .commands { } is declared at the scene level. Each main window has one WindowCommands (Commands/WindowCommands.swift), held by SignedInRootView beside its SceneNavigator, put in the environment and published with focusedSceneValue. A menu reads the front main window’s object with @FocusedValue(\.windowCommands); with a compose or Settings window in front, or none open, it reads nil and the Message, Mailbox and Feeds menus and iPadOS ⌘, dim. New Message stays live: it opens the compose scene itself (#1162).

A menu, or a window’s own button (the Mac toolbar’s Refresh, the iPad Settings gear), sends a WindowCommand, which bumps its own count; a feed or tree command names its action in its case. Surfaces answer with .answersCommand(_:). They report what menus can act on (reportsMessageMenuAvailability, reportsFeedMenuAvailability) keyed by the surface they sit in, and the menus read the surface in front: the window on the wide layouts, the tab in front on the tab layouts, which keep every tab they have shown mounted (FrontSurfacePolicy). The feed catalog and sidebar trees, one handler a window, answer from a tab behind (whileBehind). SharedChordPolicy gives each chord the Feeds menu shares with the Message or Mailbox menu (⌘T, ⌘⇧8, ⌥⌘T) to the section in front.

The shared Message menu (MessageMenuCommands) carries Reply ⌘R, Reply All ⌘⇧R, Forward ⌘⇧J, Mark as Read/Unread ⌘T, Flag/Unflag ⌘⇧8 and Move to Folder ⌘M, which deliberately shadows Window → Minimize (custom menus match first). Dispose (⌘⌫) is NOT a menu item: menu equivalents fire app-wide, so it would steal delete-to-line-start in a draft. A hidden window-scoped button carries it (DisposeChordButton): the reader’s for one open message, the list’s for a multi-selection, only while that surface is in front and owns the chord. Esc and ⌘A stay focus-scoped on the list, so the search field keeps them.

A compose request is not a menu command either. A window’s New Message, Reply and Forward, and a mailto: link, go to AppState.compose (ComposeCoordinator) with the main window they are for (MainWindowCommandScope’s window identity; for a link, the window last in front). That window’s compose surface (ComposeRequestRouter) shows the seed, as a compose window or, on a single-window host, a sheet. A request that names no window, or one whose window has no compose surface (none is recorded as last in front, or it has closed), goes to the window opened last. A seed nothing can show yet waits its turn: behind its window’s open sheet, or, on a cold launch and while signed out, for the first window that can. No request reaches two windows. Data-change reloads (Mark All as Read, Empty Trash, push actions) are not commands: they bump the mail store’s listRefreshTick, which every list observes (#1824). A drag names the list it lifted from, and only that list performs a sidebar drop.

A link from outside a window uses the same identity. A tapped notification, a Spotlight result and Siri’s Open Folder each go to exactly one main window through DeepLinkRouter: the window the system aimed at when it says (on iPad, the window a notification is shown over, or the one that received the Spotlight result), else the window last in front, else the window opened last. With no main window yet, as on a cold launch, the link parks for the first window to open; a sign-out or another account’s sign-in drops it.

Platform polish