Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Push Notifications Plan

Context

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 ApiBackedImapClient over 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:

  1. Apple’s infrastructure must not see notification content. Subject, sender, and snippet are visible to the user but not to APNs. This rules out putting message details in the APNs payload directly.
  2. The wake-signal path must not couple the IMAP container to AWS Lambda. Inbound delivery already runs through Sendmail and Procmail in the imap container; push enqueueing is a side-effect of delivery and should be decoupled from APNs send latency or failure.
  3. In-notification actions must be supported. 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.

Goals

Non-goals

Architecture

Component diagram

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

Message flow

  1. Mail arrives via the smtp-in tier and is relayed to the user’s mailstore on the imap tier.
  2. The 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.
  3. A push Lambda (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.
  4. The APNs payload is a wake signal only: {"aps": {"alert": "New mail", "mutable-content": 1, "category": "MAIL_MESSAGE", "sound": "default"}, "msgRef": {"folder": "INBOX", "uid": 4271}}.
  5. On the device, the Notification Service Extension wakes, calls a thin enrichment endpoint (/push/envelope) with the user’s existing Cognito JWT, and replaces the alert text with From <sender> | <subject> | <snippet>.
  6. The OS displays the enriched notification. If the NSE times out or the network is unreachable, the OS displays the fallback "New mail".
  7. 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 Read and Archive run through the session’s ApiBackedImapClient (the /set_flag and /move_messages Lambda endpoints), not LiveImapClient (see PushRegistrar.handleNotificationAction).

Why a fetch-on-wake enrichment endpoint instead of reusing CabalmailKit’s IMAP client

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.

Why a separate SQS queue instead of synchronous Lambda invocation from Procmail

Backend components

New DynamoDB table: 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 is group.com.cabalmail.Cabalmail — not the com.cabalmail.app / com.cabalmail.mac / group.com.cabalmail.app names used throughout this plan.

Tokens are encrypted at rest with the existing customer-managed KMS key. The table is in the existing backup plan.

New Lambda functions

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.

Procmail recipe change

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 $LOGFILE via 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.

SQS queue and IAM

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).

Client components

iOS app changes (apple/Cabalmail)

NSE implementation (apple/Cabalmail/NotificationService)

A new minimal target with no third-party dependencies. Pulls only Foundation and UserNotifications. The body:

  1. Read msgRef from request.content.userInfo.
  2. Read api_url and access token from the App Group container.
  3. Synchronously (within the NSE async context) URLSession.data(from:) against /push/envelope with a 10-second timeout.
  4. On success: replace bestAttemptContent.title with the sender, bestAttemptContent.body with subject + "\n" + snippet, and call the completion handler.
  5. On failure (timeout, network error, non-2xx, decode error): call the completion handler with the original content. The fallback "New mail" ships.

Archive folder discovery

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.

Foreground behavior

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.

Privacy posture

What APNs sees per push:

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:

What the device sends to our backend at notification time:

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.

Phasing

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.

Phase 1: Token registration plumbing

Phase 2: Server-side dispatch

Phase 3: Enrichment

Phase 4: Categories and actions

Phase 5: Per-folder opt-out and preferences

Phase 6: macOS parity

Operational concerns

Quiesce

Quiesced environments (see docs/quiesce.md) scale ECS services and the NAT instance to zero. The push path degrades cleanly:

Multi-device collapse

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).

Read-state propagation

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.

Token cleanup

push_dispatch deletes tokens on:

A scheduled Lambda runs weekly to GC tokens with last_seen_at older than 90 days as a backstop.

Monitoring

Add CloudWatch metrics published by push_dispatch:

Alerts in terraform/infra/modules/ecs monitoring stanza:

Open questions