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
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)
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.
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.
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.
scripts/build-sim.shcd 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:
CODE_SIGNING_ALLOWED=NO. The CI build recipe strips
entitlements, and sign-in then fails with keychain error -34018.
Simulator ad-hoc signing (the default) is sufficient. Do not try to
repair an unsigned build afterwards with codesign --entitlements —
that produces launch denials.ENABLE_DEBUG_DYLIB=NO, without which the notification
service extension’s preview-dylib link step fails on command-line
builds.ONLY_ACTIVE_ARCH=YES keeps it quick.Tools/SimDriveA 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:
accessibilityIdentifiers
in area.control form — signin.username, mfa.verify,
message.row.<uid>, folder.row.<path>, compose.subject, and so
on. Prefer id: queries over text: (label matching breaks when copy
changes) and over xy: coordinates (which break on rotation and
layout changes). When adding an identifier to a row, pair it with
.accessibilityElement(children: .contain) — a bare identifier merges
the row’s subtree into one accessibility element, which hides revealed
swipe buttons from both XCUITest and VoiceOver.message.row.<uid> in a
folder list and message.row.<folder>/<uid> on the global search
surface, whose results span folders (MessageRowIdentifier, #1991).
<folder> is the row’s own /-delimited path, so a search result in
Sent is message.row.Sent/316 and one in a nested folder is
message.row.Archive/2026/7. A folder list’s Unread / Flagged pills
are searches narrowed to that list’s folder and keep the bare form.Secure fields. A SwiftUI SecureField does not accept the
keyboard assistant bar’s Paste button, but does accept hardware Cmd-V.
Seed the simulator pasteboard host-side and paste:
printf %s "$PASSWORD" | ./simdrive pbcopy # simctl pbcopy under the hood
./simdrive cmd tap id:signin.password
./simdrive cmd cmdv
This keeps secrets out of the command files and the harness logs, which is the required handling.
Scrolling, and the visionOS restriction. scroll <query>
dir:up|down swipes within the element you name (dir:down reveals
what is below it), which is the only scroll visionOS accepts: an
application-anchored coordinate — what drag from:… to:… and tap
xy:… use — fails there with Failed to synthesize event: Received
invalid scene ID (nil) and takes the runner with it. Off-screen
SwiftUI rows are exists=false, not merely unhittable, so no
identifier reaches them until something scrolls them into existence.
The element you name is only the gesture’s anchor, so a section header
is a perfectly good one.
Say where you are going, not how far: scroll <query> dir:down
until:<query> sweeps until the second element is on screen, up to a
budget of eight sweeps. That is what a recorded recipe wants, and
amount: cannot express it. A synthesized press-and-drag always
flicks, so one sweep moves roughly 270 points of content on an
874-point window whatever the arithmetic asked for and every amount:
below about 0.3 is the same command; and the amount: path stops
measuring when the element you named scrolls out of the tree, which is
the usual case, so the same request travels a different distance
depending on an anchor chosen for unrelated reasons. until: has to
come last — the rest of the line is its query, which is how a label
with spaces gets through.
drag and swiperow
synthesize touches; pscroll synthesizes pointer scrolling, which is
what a trackpad’s two-finger swipe sends on iPadOS. UIKit routes the two
differently, so a control can answer one and ignore the other: the
iPadOS 27 message-list swipe did exactly that until it gained a
trackpad path of its own. Check both when changing anything a row
swipe depends on.drag ... hold:<s>
or swiperow ... hold:<s> and screenshot from outside during the
hold (xcrun simctl io <udid> screenshot out.png).Not Now). Script
their dismissal (tap text:Not Now) or pre-seed the simulator before
measuring anything.sys queries. Permission alerts,
AutoFill sheets, edit menus and the visionOS keyboard can live in a
system process rather than the app’s hierarchy — and when they do, an
app-scoped query reports them absent while the app underneath stays
inert until they are answered, so a tap/type that “succeeds” and
changes nothing is the signature. Which UI lands where is not worth
predicting: try text:/id: first, and when the element is missing
but visible on a screenshot, re-run the query with the sysid: /
systext: prefix. sysdump shows the system-side tree.
The hosting process is probed, not assumed, because it differs by
platform: iOS and iPadOS have com.apple.springboard, while visionOS
has no SpringBoard at all and splits the shell across
com.apple.Reality* processes. Every result reports which one
answered (tapped systext:Allow in com.apple.RealityNotifications);
sysapp <bundleid> pins one when a host is not on the candidate list.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); 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.
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 and prod (lowercase — the
upload jobs look the group up by name):
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.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.
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.
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 Groups + Associated Domainscom.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 Groups +
Associated Domainscom.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 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.
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:
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.
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.
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.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.
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.
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:
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 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.
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”).
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.
CabalmailUI moduleapple/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:
CabalmailUI is public: the
type, the initializer it calls, and each member it reads. Everything
else stays internal, and the test bundles reach it through
@testable import CabalmailUI.-force_load it, so every object file reaches the app as it would
if the sources were compiled there, including protocol conformances
that nothing names by symbol (Swift looks those up at run time, and an
archive member nothing references is otherwise left out). CabalmailUI
imports CabalmailKit and CabalmailShared without linking them, and
CabalmailMacTests and CabalmailiOSTests depend on CabalmailUI and
CabalmailShared with link: false: the host app carries every one of
these modules (CabalmailShared inside the Kit), and a second copy
splits their types (as? CabalmailError casts fail). Any new target
that links the library needs the same -force_load.Image("CabalmailMark")
resolves against the app bundle) rather than through generated asset
symbols, which exist only in the app modules.Platform/HostPlatform.swift and
Platform/ConfirmationDialogPolicy.swift. Moving one means updating
its path in project.yml, and neither may import CabalmailUI. The
stores the Safari web extensions read are not compiled by path: they
live in the CabalmailShared module (see “Extension-shared values”
below), which both Safari appexes link.SafariWebExtensionHandler.swift, lives in
apple/CabalmailMacWebExtension/ despite the name, and
apple/CabalmailWebExtension/ holds only the iOS appex’s Info.plist
and entitlements. Renaming them touches the source, Info.plist and
entitlement paths in project.yml, .swiftlint.yml’s folder list and
the changelog gate’s folder list for no change in behaviour, so it waits for XcodeGen target
templates, which a future extension would bring.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.
A view that differs by platform does not write #if os(...). The
difference goes in one of two places:
ShellLayout, and drawn by a
layout shell in Shell/. A shared view reads \.shellLayout from the
environment, or a HostPlatform capability when the question is about
the host rather than the window (columnScopedToolbar,
drawsOverPassthrough, settingsOpensBothColumns,
alwaysWindows, reordersInEditMode).Platform/:
one call the feature view makes on every platform, with the
conditional inside it. inlineNavigationTitle(), textEntry(_:),
editButtonToolbar(), and the list and form style helpers are
adapters of this kind. A file that wraps a
UIKit or AppKit view (UIViewRepresentable, NSViewRepresentable and
their view-controller forms) is an adapter too, wherever it lives.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
CabalmailShared moduleThe 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/):
AppGroup.identifier, the App Group whose UserDefaults suite the
apps write and the extensions read.ExtensionControlDomainStore and PrivateLinkTokenStore, the two
stores the Safari web extensions’ native handler reads: the control
domain the app signed in to, and the private-link token rows (#1765).
The app writes both. extensions/shared/test/privateLink.test.ts reads
the token store’s source by path to check its token alphabet, and
extensions.yml runs that test on a pull request that changes the
file, so a move updates both paths.PushHandoff and PushTokenPayload: where the app leaves the API URL
and the Cognito ID token for the notification service extensions (the
defaults key, the keychain service, account and access-group suffix),
and the JSON the token is stored as. PushEnrichmentStore writes them;
CabalmailNotificationService/NotificationService.swift reads them.PushMessageCoordinates and PushEnvelope, the two push wire formats:
the payload’s msgRef (folder, uid hint and msg_id; a uid of 0 or an
empty msg_id reads as none), which is also the /push_envelope
request body, and that endpoint’s reply. The notification extension
parses, sends and patches with them; the Kit’s fetchPushEnvelope
sends and decodes with them; the app’s PushMessageRef parses through
them and the macOS in-app enrichment writes with them.How it is linked:
CabalmailShared product and import it, never
the Kit. It is a static product with no
resources, so its code lands in each extension’s own binary and
nothing new is embedded or signed.os.Logger lint rule exempts only CabalmailLog and the notification
extension) and no UI imports, and leave its product type automatic. A
resource would add a bundle to every extension, and a dynamic product
would put a framework inside each .appex, which App Store upload
rejects.PushHandoffContractTests pins each Swift constant to the shipped
value, so a change on the code side fails the Kit tests instead of
quietly turning every enriched notification back into “New mail”.CabalmailKit-Package. With two
library products, Xcode gives only that scheme a test action, so
apple.yml and scripts/build-apple.sh run
xcodebuild test -scheme CabalmailKit-Package from
apple/CabalmailKit. swift test is unaffected.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.
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.
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.
.http(status:body:). Any non-2xx
from the Lambda API, or from a presigned S3 URL, that the next two
bullets don’t cover, with body the reply as text. Its copy is the
body’s status string, or its message string when there is no
status, if that string is more than one word: a one-word status
such as unable doesn’t fall through to message, and a handler’s
{"Error": ...} body isn’t read (#1918). Otherwise it is “The server
couldn’t complete that request (NNN).” The callers that act on a
particular failure compare the status: a 409 duplicate_in_flight from
/send becomes .sendInFlight, a 409 from /set_rules becomes
RuleSetConflictError, and a 400 from /fetch_bimi is cached as “no
logo”..server(code:message:). Three sources
produce it: the RSS API’s error tokens (not_a_feed,
needs_credentials, …), which FeedErrorText maps to copy; Cognito’s
exception names (NotAuthorizedException is .invalidCredentials or
.authExpired instead, and an unreadable reply gets the code
Unknown); and the config.json fetch, with its HTTP status as the
code. Some views show .server’s message as written: Siri, the feed
views for a token they have no copy for, and the sign-in form (bare for
a Cognito trigger’s copy, after “Server error:” otherwise; a mistyped
second-factor code gets its own sentence). An RSS token carries the
reply’s Error sentence and every other Lambda API failure is .http,
so none of them shows a raw Lambda API reply..authExpired. A 503 with {"status": "maintenance"} is
.maintenance(message:), whose message is shown as written. A
presigned S3 URL gets neither: its 401 or 503 is plain .http..decoding. Every strict decode of a
Lambda API reply, the RSS endpoints included, goes through
URLSessionApiClient.decodeReply, which names the endpoint
(“list_envelopes returned an unexpected reply”) and writes where the
decode stopped (its kind and coding path, never a field’s value) to
CabalmailLog. A few
reads are lenient on purpose and fall back instead. The config.json
fetch and the Cognito calls decode on their own paths. A URL the API
hands back for the client to fetch (the presigned message, attachment,
inline-image and upload URLs, and the BIMI logo) is followed only when
it is absolute http or https with a host
(URL(followableReplyString:)). Anything else fails the call as
.decoding, which for /fetch_bimi is a lookup the cache retries
rather than a cached “no logo”..network. URLSessionHTTPTransport turns every
URLError but the caller’s own cancel into .network. A dropped
connection, a timeout or a cancel nobody asked for is retried once first
when a second copy of the request is safe to send: every GET, the
presigned S3 requests, and the writes and Cognito operations
RequestReplayPolicy lists. Any other write is sent once, since its
first copy may have landed. request-replay.md has
the table. A reply that isn’t HTTP at all is .transport..cancelled. Only when the request’s own task
was cancelled, as when SwiftUI tears down a view’s .task. Callers that
stay quiet on a cancel read Task.isCancelled rather than matching
.cancelled, because some paths answer a cancel with something else:
the folder list, for one, falls back to its saved copy..storage. A keychain call; see the
storage section below.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.
KeychainSecureStore, kSecUseDataProtectionKeychain = true). A
keychain call that fails (any OSStatus but success, or not-found where
that means absent) throws CabalmailError.storage, never .transport:
the launch restore counts .transport as “offline” and passes, and a
refresh whose new tokens can’t be saved must not (#1808). The restore
lands such a failure on the error status with the tokens kept; its first
token read is a try?, so a keychain that can’t be read at all (before
the first unlock after a restart) stays signed out instead.CognitoAuthService
scrubs the imap.username and imap.password items older builds
wrote.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).AsyncThrowingStream over folder statusThere 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.
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.
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/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:
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. 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.
/sendNeither 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.
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.CabalmailUI/Compose/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.CabalmailUI/Addresses/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 + server syncCabalmailKit.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.
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.
Folder counts first, on its own: it governs the badges
on mail folders and feeds alike (unread / total / both). Then two
sections, Email messages and Feed items — the feed reader is part of
the app, not a category of its own. Email messages: Mark as read
(manual / on open), Load remote content (off / ask / always),
Default view. The manual default matches
the React app, where the user always explicitly marks messages read
via the swipe action, toolbar button, or context menu. Feed items:
its own Mark as read (rss_mark_as_read), so the two habits can
differ.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, and the swipe label +
icon follow the preference too — the two advance pickers, and
Leading swipe / Trailing swipe, which bind each edge of a message
row to Toggle read, Toggle flag, Dispose (which follows Dispose
action and the in-Trash / in-Archive overrides), or None. Feed items:
Leading swipe / Trailing swipe for feed item rows (Toggle read,
Toggle flag, None). The same action may be bound to both edges.
The edges are named for layout direction, not left / right, so a
binding means the same gesture under RTL. Synced as swipe_leading /
swipe_trailing and rss_swipe_leading / rss_swipe_trailing; the
four ride the app map as a set, gated off the wire until the user
sets one or a fetched map carries one. OPML import lives in the feeds
sidebar’s + menu and export under the share button beside it (both
also in the macOS Feeds menu); neither is a setting.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 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.
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.
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.
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.