Skip to content

Support iOS critical alerts via X-Apple-(Critical|Sound|Volume) headers - #1908

Open
tobehn wants to merge 6 commits into
binwiederhier:mainfrom
tobehn:apple-critical-headers
Open

tobehn wants to merge 6 commits into
binwiederhier:mainfrom
tobehn:apple-critical-headers

Conversation

@tobehn

@tobehn tobehn commented Aug 19, 2026 •

Copy link
Copy Markdown

Implements the server side of iOS critical alerts using the header approach from the Jun 30 discussion in #1235: the publisher decides whether a message breaks through Focus, Do Not Disturb and the mute switch. Supersedes #1778. This is the server contract for the iOS work in #1680 (2A).

Behavior — an explicit X-Apple-Critical always wins; without it, max priority stays critical, matching the already-merged app behavior (binwiederhier/ntfy-ios#44):

X-Apple-Critical Priority Critical alert
1/yes/true any yes
0/no/false any no (explicit opt-out)
(not set) 5 yes (backwards compatible)
(not set) 1–4 no

X-Apple-Sound and X-Apple-Volume (0.0–1.0, enforced by iOS) are optional. Also available as query params and as a nested apple object in JSON publishing; subscribers see the options in the message JSON (for the NSE).

What the commits cover:

  • Message model + cache schema v16 (SQLite + Postgres): the options are persisted, since delayed messages are re-sent from the cache and must not silently lose the critical flag
  • APNs payload: sound dict with critical/name/volume, interruption-level inside the aps dict, apns-priority: 10
  • Three paths that would otherwise silently drop the flag: toPollRequest (topics without anonymous read), forwardPollRequest (self-hosted → upstream, including the priority-based fallback) and the receiving side in handlePublishInternal
  • Strict validation (40059–40061): a silently dropped critical flag on an alarm is worse than a rejected publish; rejects NaN, zero volume and control characters
  • Docs: publish.md section, parameter tables, subscribe/api.md, config.md, releases.md

Open question: the priority-5 fallback changes behavior for existing publishers without a server config gate. Should it ship enabled, behind a config option, or header-only? Happy to adjust.

Limitations: delivery is verified down to the exact APNs payload (unit + two-server relay tests, real v15→v16 migration, race detector, sqlite/postgres), but I cannot verify on-device behavior — that needs an app build with the critical alerts entitlement. Behavior on non-entitled installs (current App Store app) should degrade to a regular alert, but is untested. CLI (ntfy publish) and Go client support can follow in a separate PR if needed.

tobehn added 6 commits August 30, 2026 16:44
Adds an optional AppleOptions struct (critical, sound, volume) to the
message model, persisted as a JSON TEXT column following the "actions"
precedent. Persistence matters because delayed messages are re-sent from
the cache and must not silently lose the critical flag.

Part of the X-Apple-(Critical|Sound|Volume) header support discussed in binwiederhier#1235.
An explicit X-Apple-Critical header (bool) creates the AppleOptions on the
message; sound and volume are only read for critical messages, since they
have no effect otherwise. Unlike the lenient cache/firebase bool params,
invalid values return HTTP 400: a silently dropped critical flag on an
alarm is worse than a rejected publish. The JSON publish endpoint accepts
the same options as a nested "apple" object.

Relates to binwiederhier#1235.
…ream forwards

Sets the critical sound (with configurable name and volume, defaulting to
"default"/1.0), apns-priority 10 and interruption-level "critical" in the
APNs payload. Precedence: an explicit X-Apple-Critical value always wins;
without it, max priority (5) messages are critical for backwards
compatibility.

Two paths would otherwise silently lose the flag:
- toPollRequest: topics without anonymous read access turn messages into
  poll requests, which must keep the iOS options
- forwardPollRequest: self-hosted servers forward poll requests to an
  upstream server, which delivers the APNs alert and needs the flag

Relates to binwiederhier#1235.
Fixes found in review, squashed into one commit:

- handlePublishInternal replaced a poll request publish (X-Poll-ID) with a
  fresh message, discarding the parsed iOS options; the receiving server
  now keeps them, so forwarded poll requests stay critical
- forwardPollRequest only forwarded an explicit critical flag; it now uses
  appleCritical(), so the priority-based fallback reaches the upstream
  server too (appleCritical moved to server.go for nofirebase builds)
- The JSON publish path used the model struct directly, so "volume":0 and
  an empty "apple" object silently bypassed validation and the priority
  fallback; a dedicated input struct with pointer fields fixes both
- X-Apple-Critical is now case-insensitive like other bool parameters, and
  sound names reject control characters (MIME-decoded headers)
- Error texts say "parameter" instead of "header", matching the codebase;
  test names follow the TestServer_*/TestStore_* conventions

Relates to binwiederhier#1235.
…t for iOS critical alerts

- docs/publish.md: note the new iOS default behavior in the max priority row
- docs/config.md: describe the forwarded critical flag in the iOS instant
  notification setup, including the behavior with older upstream servers
- TestStore_MessageFieldRoundTrip: populate and verify the new apple field

Relates to binwiederhier#1235.
@tobehn
tobehn force-pushed the apple-critical-headers branch from 2d4d7db to 749599b Compare August 30, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant