Host your own email and enhance your privacy
The Apple clients receive new-mail notifications via the Apple Push Notification service (APNs). The design keeps message content out of Apple’s infrastructure: the server sends a content-free wake signal, and the device enriches it locally before display.
flowchart LR
imap["imap container<br/>procmail :0c recipe"] -->|"spool file"| drain["push-spool-drain<br/>(supervisord)"]
drain -->|"{user, folder, uid, msg_id}"| sqs["cabal-push-queue"]
sqs --> dispatch["push_dispatch Lambda"]
dispatch --> tokens["cabal-push-tokens"]
dispatch -->|"content-free alert"| apns["APNs"]
apns --> device["device NSE"]
device -->|"/push_envelope"| api["API Gateway"]
:0c) recipe after each local delivery on
the imap tier. push-enqueue.sh writes one JSON wake signal into
/var/spool/cabal-push; the push-spool-drain supervisord program
forwards spool files to the cabal-push-queue SQS queue and ages out
anything it cannot send within an hour. The split keeps the delivery path
fast and credential-free: the per-user delivery agents never hold AWS
credentials, and a slow or unreachable queue never blocks or fails mail
delivery. Messages the spam rule files away are never enqueued.push_dispatch Lambda consumes the queue, looks up the recipient’s
registered device tokens in the cabal-push-tokens DynamoDB table,
filters by each token’s folder opt-in, and sends one APNs request per
device. The payload is "New mail" plus a message reference — no sender,
subject, or body./push_envelope with the user’s own Cognito JWT to fetch
sender/subject/snippet and rewrites the notification before it is shown.
If that fails (offline, token expired, timeout), the generic "New mail"
alert ships instead./push_register (on every app launch and
token rotation) and remove them through /push_deregister (sign-out).
push_dispatch also prunes tokens APNs reports as gone (410
Unregistered, BadDeviceToken), so an uninstalled app stops receiving
pushes without operator involvement.What APNs sees per push: the device token it already knows, the app’s bundle
id, an AWS egress IP, and a payload containing only the alert text "New
mail", a folder name, a UID integer, and a Message-ID string.
Push is inert until an APNs auth key is provisioned; without one,
push_dispatch logs that it is unconfigured and drops wake signals (they do
not pile up in the DLQ). Per environment:
.p8 file (one chance) and note the Key
ID (shown on the key’s page) and your Team ID (Membership
details). The key has no expiry and no role; creating it requires the
Admin or Account Holder role on your Apple account.Populate the SSM parameters (from CloudShell in the target account):
aws ssm put-parameter --name /cabal/apns/team_id --type SecureString \
--value 'YOURTEAMID' --overwrite
aws ssm put-parameter --name /cabal/apns/key_id --type SecureString \
--value 'YOURKEYID' --overwrite
aws ssm put-parameter --name /cabal/apns/private_key --type SecureString \
--value file://AuthKey_YOURKEYID.p8 --overwrite
Check the endpoint matches how the app reaches devices. TestFlight and App Store builds register production tokens, so a deployment whose every environment installs from TestFlight keeps the default (production) endpoint everywhere and skips this step. Only development-signed builds (Xcode direct installs) register sandbox tokens:
# only for an environment serving development-signed builds:
aws ssm put-parameter --name /cabal/apns/endpoint --type SecureString \
--value 'https://api.sandbox.push.apple.com' --overwrite
A token sent to the wrong endpoint fails with BadDeviceToken and is
pruned; re-launching the app re-registers it.
One key works for every app under the same team (iOS and macOS); the dispatch Lambda selects the APNs topic from each token’s registered bundle id. The same key can be shared across environments or issued per environment — Apple allows two active APNs keys per team, which is also what makes rotation possible.
The .p8 key is high-value: whoever holds it can send arbitrary
notifications to every Cabalmail device. It is stored only in SSM
(SecureString), readable only by the push_dispatch Lambda role. To rotate:
/cabal/apns/key_id and /cabal/apns/private_key in each
environment.aws lambda update-function-configuration --function-name push_dispatch
--description "key rotation $(date +%F)"), and confirm pushes still
arrive.Revoking first inverts the order of operations and takes push down until the new key lands everywhere.
The iOS app needs one-time Apple developer setup before push registration works on real devices. The generic signing mechanics (certificates, profile creation, secret encoding) live in docs/apple.md; the push-specific portal work, in order:
group.com.cabalmail.Cabalmail. It carries the API URL and the mirrored
Cognito token from the app to the Notification Service Extension.com.cabalmail.Cabalmail):
Push Notifications, and App Groups configured with the group
above. Saving this warns that existing profiles are invalidated — that
is step 4. Keychain sharing needs no portal capability.com.cabalmail.Cabalmail.NotificationService,
explicit) with App Groups only — the extension never registers for
push itself, so it does not get the Push Notifications capability.IOS_APP_STORE_PROFILE with the re-issued profile and add
IOS_NSE_APP_STORE_PROFILE (base64 each; stray whitespace is stripped
by the workflow). Until the NSE secret exists, both TestFlight
upload legs skip with a warning — it doubles as the “push signing assets
are ready” sentinel, so neither leg can fail codesign against a stale
profile.The macOS app repeats the same shape with its own identifiers — the backend needs nothing either way (the dispatch Lambda selects the APNs topic per registered token, and the same APNs key covers every app on the team):
group.com.cabalmail.Cabalmail.com.cabalmail.CabalmailMac: Push Notifications + App Groups;
re-issue its App Store profile afterwards.com.cabalmail.CabalmailMac.NotificationService: App Groups only,
plus its own App Store profile — minted with
scripts/make-mac-profile.py (needs the App Store Connect API .p8
at hand), because the portal UI cannot produce macOS profiles for
universal App IDs.MAC_APP_STORE_PROFILE, add
MAC_NSE_APP_STORE_PROFILE (the macOS upload leg’s skip-gate
sentinel). The optional notarized-artifact leg additionally needs
MAC_NSE_DEVID_PROFILE alongside the existing Developer ID pair.On macOS the extension rarely gets to run: the system notification daemon
kills service extensions before they receive the notification (a
long-standing, unresolved platform defect). The dispatch Lambda therefore
sends Macs a silent push (content-available: 1 with the msgRef, no
alert) instead of an alert push. A running Mac app — focused or not —
receives it, fetches the envelope, and posts an enriched local
notification itself: a full banner when the app is unfocused, sound only
(no banner) when it is frontmost, since the app already displays the mail.
If the fetch fails, the app posts a generic “New mail” notification
instead, so nothing is dropped silently. A quit Mac app receives no
notification at all — a deliberate trade, since the paired iPhone covers
that case with a fully enriched banner. That gap only opens when the
user quits explicitly, and two residency affordances (Settings >
Notifications) exist to make that rare: an opt-in launch-at-login item
(off until the user enables it; the toggle mirrors the system’s
SMAppService status, pointing at System Settings > General > Login
Items when approval is pending) and a menu-bar presence (on by default)
whose status-item menu shows the Inbox unread count with Open / New
Message / Quit actions, keeping the app legibly alive with every window
closed. The extension ships regardless (a monthly automated check
watches for the macOS fix that would let it take over the quit-app case
without an app change).
cabal-push-queue retains signals for one hour (a
stale “new mail” push is noise, not news) and dead-letters to
cabal-push-dlq after 5 failed receives. DLQ depth is visible in the SQS
console; messages there are safe to purge — the mail itself was delivered
normally./cabal/lambda/push_dispatch
(CloudWatch), including token prunes and per-send failures. Each
invocation emits Sent / Failed / LatencyMs metrics in the
Cabal/Push namespace via CloudWatch EMF./var/spool/cabal-push inside the imap container is sticky
world-writable (like /tmp); the drain daemon authenticates each signal
file by comparing its owner to the user it names, drops malformed or
stale files, and the enqueue side sheds new signals if the spool backs up
past 1000 files. The spool is container-local scratch — it is not on EFS
and does not survive a task replacement, which loses at most the last
seconds of wake signals, not mail.last_seen_at on each cabal-push-tokens row is
updated on registration and refreshed by successful pushes;
last_failure records the most recent APNs rejection reason. Rows for
uninstalled devices are pruned automatically on the next push attempt,
and the weekly push_token_gc Lambda reaps rows idle for 90+ days as
the backstop for devices no rejection ever surfaces (logs at
/cabal/lambda/push_token_gc)./push_envelope returns 503 while a
planned IMAP redeploy is in flight; devices fall back to the generic
alert. Nothing needs doing.