Host your own email and enhance your privacy
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.
apple/
project.yml # XcodeGen spec (generates Cabalmail.xcodeproj)
Cabalmail.xcworkspace/ # Workspace referencing the generated project + kit package
Cabalmail/ # iOS / iPadOS / visionOS app target (SwiftUI)
CabalmailMac/ # Native macOS app target (SwiftUI)
CabalmailWatch/ # Watch companion app (address management only),
# embedded in the iOS product
CabalmailKit/ # Shared Swift package — networking, models, auth, caching
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).
xcodebuild: error: tool 'xcodebuild' requires Xcode, but active
developer directory '/Library/Developer/CommandLineTools' is a command
line tools instance, point xcode-select at your Xcode installation:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
com.apple.FinderInfo extended attributes mid-build, which
codesign rejects with resource fork, Finder information, or similar
detritus not allowed. Keep the checkout outside ~/Desktop and
~/Documents when those are synced, or clone to a path like ~/Code.-derivedDataPath into
the repo tree (for the same xattr reason). Omit the flag to use
~/Library/Developer/Xcode/DerivedData instead.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
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.
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.
APPLE_TEAM_ID secret.APPLE_DISTRIBUTION_CERT_P12 /
APPLE_DISTRIBUTION_CERT_PASSWORD..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.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) |
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) |
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.
IOS_APP_STORE_PROFILE / IOS_NSE_APP_STORE_PROFILE /
WATCHOS_APP_STORE_PROFILE / MAC_APP_STORE_PROFILE / (optional)
MAC_DEVID_PROFILE secrets.APP_STORE_CONNECT_API_KEY_ID /
APP_STORE_CONNECT_API_ISSUER_ID / APP_STORE_CONNECT_API_KEY_P8
triple.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.
Stage, Prod).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/workflows/apple.yml has four jobs. The kit-test and app-build
jobs 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.
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.
Keychain Access, or Applications
→ Utilities → Keychain Access).Apple Distribution: Your Name (TEAMID) where
TEAMID matches APPLE_TEAM_ID.
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..p12.openssl rand -base64 24 | pbcopy and keep it on the clipboard).base64 -i ~/Desktop/cabalmail-dist.p12 | pbcopy
Paste into APPLE_DISTRIBUTION_CERT_P12. Put the export password into
APPLE_DISTRIBUTION_CERT_PASSWORD.
.p12 when done — it contains your private key:
rm ~/Desktop/cabalmail-dist.p12
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.
.cer, double-click it so
Keychain Access imports it and links it to the private key.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.base64 -i ~/Desktop/cabalmail-installer.p12 | pbcopy
Paste into MAC_INSTALLER_CERT_P12. Password into
MAC_INSTALLER_CERT_PASSWORD.
.p12:
rm ~/Desktop/cabalmail-installer.p12
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.
group.com.cabalmail.Cabalmailcom.cabalmail.Cabalmail (App IDs → iOS, tvOS, watchOS, visionOS)
— Push Notifications + App Groupscom.cabalmail.Cabalmail.NotificationService (App IDs → iOS, tvOS,
watchOS, visionOS) — App Groups only; the push Notification Service
Extension embedded in the iOS archivecom.cabalmail.Cabalmail.watchkitapp (App IDs → iOS, tvOS, watchOS,
visionOS) — the embedded watch companion appcom.cabalmail.CabalmailMac — Push Notifications + App Groupscom.cabalmail.CabalmailMac.NotificationService — App Groups
only; the push Notification Service Extension embedded in the
macOS archiveFor 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.
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.
Download each profile (click the profile → Download; the script already wrote the mac ones locally and printed their base64).
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.
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.APP_STORE_CONNECT_API_ISSUER_ID.APP_STORE_CONNECT_API_KEY_ID.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.
.p8 — it grants broad write access to your App Store Connect
account:
rm AuthKey_XXXXXXXXXX.p8
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.
base64 -i cert.p12 | tr -d '\n' |
pbcopy.DEVELOPER_ID_CERT_P12 and DEVELOPER_ID_CERT_PASSWORD..github/workflows/apple.yml has four jobs:
| Job | Runs when | What it does |
|---|---|---|
kit-test |
Any push touching apple/** or the workflow file |
SwiftLint + xcodebuild test on CabalmailKit across macOS / iOS / visionOS destinations |
app-build |
Same | Unsigned xcodebuild build for Cabalmail (iOS) and CabalmailMac (macOS) |
upload-ios |
Pushes to main or stage, with the seven signing secrets configured |
Manual-signed archive → TestFlight upload |
upload-mac |
Same | Manual-signed App Store .pkg → TestFlight upload, plus (optional) a Developer ID export → notarytool submit --wait → stapler staple → uploaded as a workflow artifact |
upload-ios gracefully no-ops (with a workflow warning) when its
required secrets are missing. upload-mac fails the workflow in the
same situation — treat a missing Mac signing secret as a release-blocking
bug. 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.
After a successful CI upload and App Store Connect processing (5–30 min), the build appears in your app’s Builds list. First-time attach:
Stage
or Prod internal group → Builds section → + → attach the
just-processed build. Toggle Automatically distribute builds on
the group if you want future uploads attached without clicking.macOS follows the same flow using the macOS TestFlight app (install from the Mac App Store).
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:
default-app-requests@apple.com
address). The form asks the submitter to confirm, among other
things, that:
mailto: scheme in its Info.plist,mailto: handler opens a new compose view with
the To: address set to the target of the URL,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.
IOS_APP_STORE_PROFILE_UUID secret.Edit apple/Cabalmail/Cabalmail.entitlements (already wired into
the iOS target’s CODE_SIGN_ENTITLEMENTS via project.yml) to
add the key:
<key>com.apple.developer.mail-client</key>
<true/>
The file ships empty so the path is set up from day one; only the key needs to be added when approval lands.
xcodegen generate) and ship a new
TestFlight build against the updated profile.Adding the <true/> value before Apple approves the entitlement
will break CI signing — the profile won’t carry it, and codesign will
refuse. Apple’s rules also 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.
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/Cabalmail/
CabalmailApp.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:
com.apple.developer.mail-clientReal 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.
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.
AppIconVision.solidimagestackThe Cabalmail target’s visionOS destination is served by a layered
AppIconVision.solidimagestack (back / middle / front layers, composited
with parallax on gaze focus), wired via a per-SDK icon-name override in
project.yml:
"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xros*]": 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.
The roadmap treats macOS as a first-class platform, so the macOS target
is native rather than Mac Catalyst. CabalmailMac/ is a separate app
that shares CabalmailKit only; views are not reused from the iOS
target.
config.jsonThe 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.
USER_PASSWORD_AUTHThe 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.
NWConnectionCabalmailKit ships a small hand-rolled IMAP client (LiveImapClient)
built on NWConnection + a byte-accurate response parser, rather than
swift-nio-imap or MailCore2. Trade-offs:
See the top-level CLAUDE.md for the production wiring:
the live mail traffic goes through ApiBackedImapClient against the
Lambda API surface, not LiveImapClient direct-to-IMAP.
NWConnection’s TLS stack attaches at connect time, so a clean STARTTLS
upgrade requires a custom framer — meaningfully more code with no
operational upside. The submission listener already binds 465 as well as
587 (see
terraform/infra/modules/elb/main.tf),
and implicit-TLS on 465 is operationally equivalent to STARTTLS on 587,
so LiveSmtpClient defaults to 465. NetworkByteStream.startTLS(host:)
throws deliberately so future contributors see exactly why the upgrade
path isn’t available.
KeychainSecureStore, kSecUseDataProtectionKeychain = true).STATUS + UID FETCH since
UIDNEXT) drops straight onto this..eml files, LRU-evicted by mtime
when the total exceeds a configurable cap (default 200 MB).AsyncThrowingStreamLiveImapClient.idle(folder:) opens a second authenticated connection
dedicated to IDLE so foreground mailbox operations aren’t blocked by the
open IDLE socket. Untagged EXISTS / EXPUNGE / FETCH events stream
via AsyncThrowingStream<IdleEvent, Error>; terminating the stream
cancels the reader task, issues DONE\r\n, and closes the connection.
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.
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.
apple/CabalmailKit/Sources/CabalmailKit/Compose/Resources/ is the
SwiftPM resource directory editor.html looks up its sibling scripts
from. editor.html 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:
react/admin/package.json and opens
PRs against it when CVEs land for marked or turndown. The next
apple.yml run after that PR merges pulls the patched bytes
automatically.node_modules, so the Apple WKWebView and the React TipTap editor
cannot diverge on the underlying library version.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.
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.
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.
/sendNeither mail tier auto-APPENDs to Sent. The /send Lambda owns the
whole outbound shuffle — Outbox APPEND, SMTP submission, and the Sent
copy (staged Bcc-free 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.
CabalmailKit.ReplyBuilder (pure value-type helper) turns an incoming
Envelope + its decoded plain-text body + the user’s owned addresses
into a seeded Draft. Handles Re: / Fwd: idempotent prefixing,
In-Reply-To / References threading, reply-all deduplication /
self-exclusion, and the “default From to the original’s addressee” rule
that makes the on-the-fly-From idiom reusable across a whole thread.Cabalmail/Views/ComposeView.swift renders the SwiftUI form. From
picker’s first menu item is always “Create new address…” (matches
docs/README.md’s primary-action framing). Attachments land via
PhotosPicker (images) and fileImporter (arbitrary documents);
mime-type derived from UTType for file imports.Cabalmail/Views/NewAddressSheet.swift mirrors the React app’s
Addresses/Request.jsx — username / subdomain / domain / optional
comment, with a Random button that seeds alphanumerics so the
mint-an-address flow stays a one-tap affordance.UserDefaults + NSUbiquitousKeyValueStoreCabalmailKit.Preferences persists through a pluggable PreferenceStore
protocol. Production uses UbiquitousPreferenceStore, which writes to
both UserDefaults (fast local reads) and NSUbiquitousKeyValueStore
(cross-device sync via iCloud). An
NSUbiquitousKeyValueStoreDidChangeExternallyNotification observer
mirrors every pushed key back into UserDefaults so subsequent
synchronous reads stay fast. The store degrades gracefully on an
iCloud-disabled install — the ubiquitous half becomes a no-op and
UserDefaults carries the whole load.
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.
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.
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.
Mark as read (manual / on open / after delay) and
Load remote content (off / ask / always). MessageDetailViewModel
schedules a cancellable 2-second task for .afterDelay that the view
cancels on disappear so we don’t mark read a message the user only
previewed.Default From address (None / one of the user’s
addresses — revoked addresses fall back to None so a stale preference
can’t persist an address that doesn’t exist any more) and a plain-text
Signature. Reply / forward flows default From to the original’s
addressee, so the default-From preference only applies to new
messages.Dispose action (Archive / Trash). MessageListViewModel
reads this on every swipe so a change mid-session takes effect
immediately; the swipe label + icon follow the preference too.Theme (System / Light / Dark) applied via
.preferredColorScheme at the App level so the whole app flips
instantly. CabalmailApp and CabalmailMacApp own the AppState and
Preferences instances; the iOS ContentView reads them from
@Environment.Bundle.main.infoDictionary) and
a link to the GitHub issues.MailboxWatcher opens a dedicated IDLE connection and emits .changed /
.reconnecting / .active ticks on an AsyncStream.
MessageListViewModel.startWatching() drives it from the message list’s
.task { } and stops it on .onDisappear. The watcher stays off while
the user is elsewhere — mailbox management, compose sheet, settings — so
the server only holds one open IDLE socket per active mailbox.
Reconnects use bounded exponential backoff (2s → 60s) per RFC 2177’s
29-minute disconnect cadence; consecutive EXISTS bursts are coalesced
on the view-model side with a 1-second refresh floor so a message sweep
doesn’t trigger N envelope fetches.
CabalmailClient.send(_:) returns SendOutcome.sent or .queued.
Transport / network errors (CabalmailError.network, connection
timeouts) queue the OutgoingMessage into the on-disk Outbox and
surface a warning toast; 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 dropped so a permanently bad
recipient can’t spin forever. One JSON file per entry under app support,
same layout as DraftStore.
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).
AppState tick countersMenu commands (File → New Message ⌘N, Mailbox → Refresh, the Message
menu) need to reach whichever view currently owns the action — but
.commands { } is defined at the scene level, so there’s no direct
reference to the focused view. AppState exposes monotonic intent
counters (composeRequestTick, refreshRequestTick, the reply /
forward ticks, and the selection-scoped toggleSeenRequestTick /
toggleFlaggedRequestTick / moveSelectionRequestTick); the views
watch them with .onChange and act on each bump. Avoids the
@FocusedValue / responder-chain dance, and the same tick flow works
on iPadOS (hardware-keyboard menu) and macOS (menu bar). The shared
Message menu (MessageMenuCommands, installed by both app targets)
carries Reply ⌘R, Reply All ⌘⇧R, Forward ⌘⇧J, Mark as Read/Unread ⌘T,
Flag/Unflag ⌘⇧8, and Move to Folder ⌘M — the ⌘M item deliberately
shadows Window → Minimize, since custom command menus are matched
before the Window menu. Dispose (⌘⌫) is deliberately NOT a menu item:
menu equivalents fire app-wide, so it would trigger from the compose
window and steal the text system’s delete-to-line-start chord
mid-draft. It rides window-scoped key equivalents instead — the detail
toolbar’s dispose button covers a single open message (and advances to
the next unread), and the message list installs an invisible ⌘⌫ button
while a multi-selection exists — so the chord acts on the mail window
only, but works there regardless of whether the list or the reading
pane has focus (users can rarely tell which it is). Esc and ⌘A stay
focus-scoped on the list: window-scoped versions would steal them from
the search field.
SignedInRootView overlays a capsule banner
sourced from CabalmailClient.reachability.changes() when the network
drops, clearing on restore.AppState.toast + showToast(_:duration:) carries
transient success / warning messages; ComposeView publishes on send
outcome (sent → success, queued → warning).MessageListView and FolderListView wrap row
content in .contentShape(Rectangle()).hoverEffect(.highlight) under
#if os(visionOS) so gaze focus visibly highlights rows without
changing iOS / macOS rendering.DebugLogView (Settings → Debug Log) renders the live
DebugLogStore tail with level chips, a Clear button, and a ShareLink
that exports the current filtered buffer. Capped at 1000 visible
entries to keep SwiftUI happy.