Host your own email and enhance your privacy
Cabalmail’s iOS app (apple/Cabalmail) currently learns about new mail only while running in the foreground, via the IMAP IDLE loop in apple/CabalmailKit/Sources/CabalmailKit/IMAP/LiveImapClient+Idle.swift. When the app is suspended or terminated, iOS does not keep the IMAP connection alive, so the user gets no signal that mail has arrived until they reopen the app. This is the largest remaining UX gap before 1.0 and the most common piece of feedback from native-app users.
Erratum (2026-08-07): Four days after this plan was written, #371 (commit 73711325) switched the production Apple clients to
ApiBackedImapClientover the Lambda API. The IDLE loop described here never ran in the shipped push-era app; foreground refresh is API polling.
This plan introduces server-originated push notifications via the Apple Push Notification service (APNs) with three constraints:
imap container; push enqueueing is a side-effect of delivery and should be decoupled from APNs send latency or failure.Open, Mark as Read, and Archive need to work without launching the full app for the latter two.The design also targets the macOS app (apple/CabalmailMac) with the same APNs path; macOS-specific notes are called out where they diverge.
Open opens the message in the app; Mark as Read and Archive complete in the background without launching the app.Replacing IDLE in the foreground app. APNs supplements IDLE; it does not replace it. While the app is open, IDLE remains the source of truth for instant updates.
Erratum (2026-08-07): By ship time the foreground app had no IDLE (#371 moved mail traffic to the Lambda API, which polls). APNs supplements polling, not IDLE.
mail-admin.<domain>. The admin mailbox is operator-facing, not user-facing.flowchart TD
mx["Inbound MX<br/>(smtp-in)"] --> imap["imap container<br/>sendmail+procmail"]
imap --> sqs["push SQS"]
sqs --> lambda["push Lambda"]
lambda --> ddb["DynamoDB<br/>tokens"]
lambda -->|APNs| apns["Apple APNs edge"]
apns --> os["OS"]
subgraph app["iOS / macOS app"]
nse["Notification Service<br/>Extension (NSE)"] -->|enrich| mainapp["main app<br/>(action handler)"]
end
os --> app
smtp-in tier and is relayed to the user’s mailstore on the imap tier.imap container’s Procmail recipe fires after each successful delivery and enqueues a JSON message to an SQS queue (cabal-push-queue) containing {user, folder, uid, msg_id}. Local delivery is not blocked on the enqueue: if SQS is unreachable the message goes to a local fallback file and is drained on the next reconcile loop.push-dispatch) consumes the queue, looks up active device tokens for the user in DynamoDB (cabal-push-tokens), and sends one APNs HTTP/2 request per token.{"aps": {"alert": "New mail", "mutable-content": 1, "category": "MAIL_MESSAGE", "sound": "default"}, "msgRef": {"folder": "INBOX", "uid": 4271}}./push/envelope) with the user’s existing Cognito JWT, and replaces the alert text with From <sender> | <subject> | <snippet>."New mail".If the user taps Open, the main app launches and routes to the message via msgRef. If the user taps Mark as Read or Archive, the OS wakes the main app in the background (~30s budget); the action handler refreshes the Cognito token if needed and uses the existing LiveImapClient to perform the IMAP operation.
Erratum (2026-08-07): As shipped,
Mark as ReadandArchiverun through the session’sApiBackedImapClient(the/set_flagand/move_messagesLambda endpoints), notLiveImapClient(seePushRegistrar.handleNotificationAction).
The NSE could in principle link CabalmailKit and call LiveImapClient directly to fetch the envelope. This was considered and rejected for 1.0.x:
We can revisit this if a future feature (e.g. fully offline notification rendering) makes IMAP-from-NSE more attractive.
imap container, in the delivery hot path. A direct Lambda invocation would mean every inbound message pays APNs send latency before delivery completes.cabal-push-tokens| Attribute | Type | Notes |
|---|---|---|
user (PK) |
S | Cognito username, same value used in cabal-addresses |
device_token (SK) |
S | APNs device token, hex-encoded, 64 bytes |
bundle_id |
S | com.cabalmail.app or com.cabalmail.mac. Determines which APNs topic to use. |
platform |
S | ios or macos. Informational; bundle_id is authoritative. |
app_version |
S | For diagnostics. |
locale |
S | For future localized fallback strings. |
enabled_folders |
SS | Folder names to push for. Empty set = inbox only. Special value * = all. |
created_at |
S | ISO 8601. |
last_seen_at |
S | Updated on each successful push or token re-registration. Used to GC stale tokens. |
last_failure |
S | Optional; set to APNs failure reason on rejection (e.g. BadDeviceToken). |
Erratum (2026-08-07): Shipped bundle IDs are
com.cabalmail.Cabalmail/com.cabalmail.CabalmailMac(NSEs append.NotificationService), and the App Group isgroup.com.cabalmail.Cabalmail— not thecom.cabalmail.app/com.cabalmail.mac/group.com.cabalmail.appnames used throughout this plan.
Tokens are encrypted at rest with the existing customer-managed KMS key. The table is in the existing backup plan.
| Function | Trigger | Purpose |
|---|---|---|
push_register |
API Gateway POST /push/register |
iOS app calls on first launch and on token rotation. Upserts the token row, scoped to the authenticated Cognito user. |
push_deregister |
API Gateway POST /push/deregister |
iOS app calls on logout or when the user disables notifications. |
push_envelope |
API Gateway POST /push/envelope |
Called by the NSE. Body: {folder, uid}. Returns {from, subject, snippet} only. Reuses get_imap_client from helper.py. Subject and snippet are truncated to safe display lengths server-side. |
push_dispatch |
SQS event source | Consumes cabal-push-queue. Per message: looks up tokens for the user, filters by enabled_folders, sends APNs requests in parallel, updates last_seen_at / last_failure, and deletes tokens on Unregistered (410) or BadDeviceToken. |
Erratum (2026-08-07): The endpoints shipped as flat routes —
/push_register,/push_deregister,/push_envelope— matching the rest of the API surface, not the slash-nested/push/*paths shown here.
push_dispatch reads the APNs .p8 key and team/key/bundle IDs from SSM SecureString parameters under /cabal/apns/. JWT generation will bundle cryptography (already a transitive dep) per-function for ES256 signing, the same way other API functions bundle their Python deps.
Add to docker/imap/configs/procmailrc:
:0c
| /usr/local/bin/push-enqueue.sh "$LOGNAME" "INBOX" "$Subject"
The :0c recipe runs as a side effect (carbon copy: delivery still continues unconditionally). The script is best-effort: it pipes a single JSON line to aws sqs send-message and exits zero regardless of result. Failures land in ~/.procmail/push-enqueue.log for diagnosis but do not affect mail flow. The script lives in docker/shared/push-enqueue.sh so it can be reused across delivery paths if needed.
Erratum (2026-08-07): Shipped as a spool-and-drain pair, not a direct SQS send: sendmail sanitizes the delivery agent’s environment, so the procmail child (
push-enqueue.sh <user> <folder>— no Subject argument) only writes a spool file under/var/spool/cabal-push, and a root supervisord daemon (push-spool-drain.sh) forwards it to SQS with the task-role credentials. Failures log to procmail’s own$LOGFILEvia stderr.
The UID is not known at procmail time. We pass LAST_UID_HINT via Dovecot’s last-uid lookup in the script, falling back to “look up newest in folder” on the consumer side if the hint is stale. This keeps the recipe trivial and avoids a Dovecot LDA hook.
cabal-push-queue is added to terraform/infra/modules/ecs (or a new push module if it grows). The imap task role gets sqs:SendMessage on this queue only. The push_dispatch Lambda execution role gets sqs:ReceiveMessage / DeleteMessage and dynamodb:GetItem / Query / UpdateItem on cabal-push-tokens.
A dead-letter queue catches messages that fail dispatch repeatedly; it is monitored via the existing alerting stack (see docs/monitoring.md).
com.cabalmail.app.NotificationService. Embed in the main app.group.com.cabalmail.app) to both the app and the NSE so the Cognito JWT, refresh token, and api_url can be shared via Keychain Access Group + UserDefaults(suiteName:).UNUserNotificationCenter.requestAuthorization, register for remote notifications, and POST the resulting token to /push/register.Register notification categories at launch:
UNUserNotificationCenter.current().setNotificationCategories([
UNNotificationCategory(
identifier: "MAIL_MESSAGE",
actions: [
UNNotificationAction(identifier: "OPEN", title: "Open", options: [.foreground]),
UNNotificationAction(identifier: "MARK_READ", title: "Mark as Read", options: []),
UNNotificationAction(identifier: "ARCHIVE", title: "Archive", options: []),
],
intentIdentifiers: [],
options: []
)
])
UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:) to dispatch on actionIdentifier. MARK_READ and ARCHIVE use the existing LiveImapClient.setFlags and LiveImapClient.move methods. The completion handler is called only after the IMAP operation resolves; a beginBackgroundTask wraps the work to extend the budget if needed.A new minimal target with no third-party dependencies. Pulls only Foundation and UserNotifications. The body:
msgRef from request.content.userInfo.api_url and access token from the App Group container.URLSession.data(from:) against /push/envelope with a 10-second timeout.bestAttemptContent.title with the sender, bestAttemptContent.body with subject + "\n" + snippet, and call the completion handler."New mail" ships.The current Dovecot mailbox config in docker/imap/configs/dovecot/15-mailboxes.conf defines Drafts, Junk, Trash, Sent but no Archive. We add:
mailbox Archive {
auto = subscribe
special_use = \Archive
}
The iOS app discovers the archive folder via IMAP LIST (SPECIAL-USE) "" "*" and caches the result in UserDefaults. If the user has renamed or hidden the folder, settings expose a folder picker. The cached folder name is what the ARCHIVE action uses.
Erratum (2026-08-07): The API-backed client cannot issue
LIST (SPECIAL-USE). As shipped, the app finds the folder named “Archive” (case-insensitive) via/list_folders, caches it, and degrades the Archive action to a no-op when absent.
When the app is in the foreground, UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:) is called with the incoming notification. We suppress the banner (the IDLE-driven UI already shows the new message) and just play a subtle sound. This avoids double-notifying.
What APNs sees per push:
com.cabalmail.app).{"aps": {"alert": "New mail", ...}, "msgRef": {"folder": "INBOX", "uid": 4271}}. The uid is an integer that, on its own, does not identify the sender, subject, or content.What APNs does not see: sender address, recipient address, subject, body, attachments, or any cleartext message metadata.
What the lock screen shows when previews are disabled:
"New mail". The NSE result is cached and revealed when the user authenticates.What the device sends to our backend at notification time:
/push/envelope with the user’s JWT and {folder, uid}. This is no more than the main app already sends during normal IMAP polling.The APNs .p8 key is high-value: a leak lets an attacker send arbitrary notifications to every Cabalmail device. Mitigations: stored in SSM SecureString with KMS encryption, IAM-restricted to the push_dispatch Lambda role only, rotatable via App Store Connect (issue new key, update SSM, revoke old after deploy). A scheduled rotation is added to the operations runbook in docs/operations.md.
Each phase is independently deployable and reversible. Phases 1-2 ship infrastructure with no user-visible change; phase 3 turns on the user-visible feature.
cabal-push-tokens DynamoDB table (Terraform)..p8 key in App Store Connect; store in SSM under /cabal/apns/.push_register and push_deregister Lambdas.cabal-push-queue SQS queue + DLQ (Terraform).push_dispatch Lambda with SQS event source.push-enqueue.sh to docker/shared/; wire into procmailrc."New mail" notifications.push_envelope Lambda."New mail" with previews off or NSE failure.MAIL_MESSAGE category with three actions in the iOS app.category: "MAIL_MESSAGE" to dispatched payloads.LiveImapClient.Archive mailbox to Dovecot config; backfill auto-create for existing users via a one-shot reconfigure.sh step.push_register to accept enabled_folders.Mirror state to the existing user preferences mechanism in lambda/api/get_preferences / set_preferences for cross-device consistency.
Erratum (2026-08-07): Notification preferences shipped per-device, not mirrored into
get_preferences/set_preferences: the folder scope lives locally (PushSettings) and server-side on the device’scabal-push-tokensrow (enabled_folders, upserted via/push_register).
Add NSE target to the macOS app.
Erratum (2026-08-07): macOS parity did not ship via the NSE: macOS’s notification daemon kills the extension before
didReceiveruns (Apple platform defect, forums threads 693011/806789). The dispatcher instead sends Macs a background push and the running app enriches and posts a local notification itself; the NSE remains shipped in case Apple fixes the platform.
/push/envelope endpoint and the same push_dispatch Lambda.bundle_id per-token.Quiesced environments (see docs/quiesce.md) scale ECS services and the NAT instance to zero. The push path degrades cleanly:
imap container is down, so no mail is delivered, so push-enqueue.sh is never invoked.push_dispatch will fail to reach API Gateway from outside the VPC (no NAT). The DLQ catches it; manual replay after un-quiesce is fine.Two devices for the same user receive two notifications. We use APNs apns-collapse-id set to msgRef.folder + ":" + msgRef.uid so a re-send (e.g. retry from DLQ) does not double-notify. Cross-device collapse is not done; each device shows its own notification. This matches user expectation (the notification is dismissed independently when the user opens the message on one device).
Mark as Read from the lock screen sets \Seen via IMAP. Other devices see the change on their next IDLE event or poll. Inversely, marking-read on the web client does not actively dismiss the iOS notification: this is a known minor inconsistency we accept in 1.0.x. A future enhancement could push a “dismiss” notification with apns-collapse-id matching the original, but it adds APNs traffic for limited gain.
push_dispatch deletes tokens on:
Unregistered from APNs (app uninstalled or token invalidated).BadDeviceToken.A scheduled Lambda runs weekly to GC tokens with last_seen_at older than 90 days as a backstop.
Add CloudWatch metrics published by push_dispatch:
cabal.push.sent (counter, dimensioned by bundle_id).cabal.push.failed (counter, dimensioned by reason).cabal.push.latency_ms (histogram, queue receive to APNs response).Alerts in terraform/infra/modules/ecs monitoring stanza:
push.failed rate > 5% over 5 minutes.bs4.get_text() with whitespace normalization, truncated to 240 chars. If the result is empty or garbled, fall back to a generic snippet."New mail". Phase 3 ships with fall-back behavior; we revisit if it fires often in practice.