Resolves wayfinder tickets #2 and #3 on the calendar-backend groundwork map (#1): - smart-tablet-screen-calendar-webhooks.md: Nextcloud CalDAV push support (none usable — Mail-in-a-Box's bundled Nextcloud predates the Webhooks app) vs Microsoft Graph webhook subscriptions (supported, with renewal-job requirements). - smart-tablet-screen-frankenphp-docker.md: FrankenPHP-based dev/prod Docker image and compose setup for the Symfony backend. Also gitignore .scratch/, the repo-local scratchpad directory for throwaway working files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QshNimeB3TVU2vMjN7tEuz
11 KiB
11 KiB
Smart Tablet Screen — Calendar Webhook vs Polling Research
Research date: 2026-09-01. All claims below are sourced against primary/official documentation, source code, or first-party API references — not third-party summaries. Each claim links its own source. Companion to docs/research/smart-tablet-screen-stack.md (§1–2 cover the base CalDAV/Graph decision this doc extends for change-detection strategy) — written for issue #2, feeds issue #5 ("Cache refresh trigger mechanism").
1. Nextcloud CalDAV (Mail-in-a-Box bundled): webhook vs sync-token
- Mail-in-a-Box pins an old Nextcloud version. Its own setup script hardcodes
nextcloud_ver=27.1.11(with a matching SHA1 for the downloaded tarball) — this is the version actually installed on any Mail-in-a-Box box today, not whatever is "latest" upstream. — mail-in-a-box/mailinaboxsetup/nextcloud.sh, GitHub - Nextcloud's own "Webhooks" capability (
webhook_listenersapp) only exists from Nextcloud 30.0.0 onward. It ships as an app inside thenextcloud/servermonorepo itself (apps/webhook_listeners/, added by PR #45475) — it is not a separately-maintained GitHub repo (nextcloud/webhook_listenersdoes not exist as an independent project; the app lives atnextcloud/server/apps/webhook_listeners). — nextcloud/server PR #45475, GitHub, nextcloud/serverapps/webhook_listeners, GitHub - Consequence: Mail-in-a-Box's Nextcloud 27.1.11 predates
webhook_listenersby three major versions (27 → 30). The app cannot be present or enabled on a Mail-in-a-Box-bundled Nextcloud instance today — it isn't a matter of the household needing to install anything extra; the underlying Nextcloud version doesn't ship the feature at all. This directly answers the ticket's "is it realistic to assume it's present" question: no. - Even where present (Nextcloud ≥30),
webhook_listenersdoes support calendar/event change events. Nextcloud's own admin manual lists supported event categories including calendar object creation, update, deletion, and movement between calendars (CalendarObjectCreatedEvent,CalendarObjectUpdatedEvent,CalendarObjectDeletedEvent,CalendarObjectMovedEvent), alongside file, tag, Forms, and Tables events. It comes bundled with core Nextcloud but is not enabled by default — an admin must runocc app:enable webhook_listeners, and registering a webhook additionally requires an admin (or delegated-admin) account via the OCS API. Delivery itself runs through a background job, triggered roughly every 5 minutes by default rather than instantly. — Webhook Listeners, Nextcloud Administration Manual, docs.nextcloud.com - Absent (or unusable) webhooks, Nextcloud's CalDAV implementation supports WebDAV Collection Sync per RFC 6578 — the standard, protocol-level alternative to full re-fetch. The client requests a sync-token via a
sync-collectionREPORT; the server replies only with items created/modified/deleted since that token, plus a new token to store for the next round. This is a generic CalDAV/WebDAV mechanism (not Nextcloud-specific), defined by the IETF standard itself. — RFC 6578 — Collection Synchronization for WebDAV, datatracker.ietf.org - This sync-token mechanism still requires a client-initiated poll — RFC 6578 makes each poll cheap (only the delta is returned) but does not give the server a way to push to the client; the client must periodically re-issue the
sync-collectionREPORT to learn whether anything changed. There is no server-initiated notification path in the CalDAV/WebDAV spec itself, and (per the point above) Mail-in-a-Box's pinned Nextcloud version has no bundled app that adds one.
2. Microsoft Graph webhook change notifications for calendar events
- Graph's
/subscriptionsAPI explicitly supports theeventresource. The Outlookeventresource is listed among subscribable resources with resource path/me/events//users/{id}/events, supporting both delegated (personal Microsoft account, viaCalendars.Read) and application permissions — confirming free Outlook.com/personal-account calendars are covered, consistent with §2 of the base stack research. — Create subscription, Microsoft Graph v1.0, learn.microsoft.com, Set up notifications for changes in resource data — Supported resources, learn.microsoft.com - Creating a subscription:
POST https://graph.microsoft.com/v1.0/subscriptionswith a JSON body containingchangeType(created,updated,deleted, comma-separated as needed),notificationUrl(the app's public HTTPS webhook endpoint),resource(e.g./me/events),expirationDateTime, and a requiredclientStatesecret the app later uses to confirm notifications genuinely came from Graph. A successful call returns201 Createdwith the subscription object including itsid. — Create subscription, Microsoft Graph v1.0, learn.microsoft.com - Validation-token handshake (mandatory, happens synchronously during subscription creation): Graph sends
POST https://{notificationUrl}?validationToken={opaqueToken}withContent-Type: text/plain. The app's endpoint must URL-decode the token and respond within 10 seconds with HTTP200 OK,Content-Type: text/plain, and a body containing exactly the plain-text (decoded) validation token — an HTML/JS-encoded response fails validation. If this handshake fails, Graph does not create the subscription (400 Bad Request) at all. — Receive change notifications through webhooks — notificationUrl validation, learn.microsoft.com - Notification payload shape delivered on change: a
POSTtonotificationUrlcarrying achangeNotificationCollection— a JSON object with avaluearray, where each entry hasid,subscriptionId,subscriptionExpirationDateTime,clientState(must be checked against the value set at creation to reject spoofed notifications),changeType,resource(path to the changed item),tenantId, and aresourceDatablock (@odata.type,@odata.id,@odata.etag,id) identifying the changed resource — by default this is a basic notification (no event body/fields, just enough to know something changed and re-fetch it). A singlePOSTmay bundle multiple notifications across subscriptions. The app must return2xxwithin 3 seconds (200if processed inline,202if merely queued) or Graph begins retrying for up to 4 hours with exponential backoff before giving up. — Receive change notifications through webhooks, learn.microsoft.com - Maximum subscription duration for
event(andmessage,contact) is under 7 days: 10,080 minutes. (Rich/resource-data subscriptions on these resources are capped much lower, at under 1 day — 1,440 minutes — but this project would use basic notifications and re-fetch via the API, so the 10,080-minute ceiling applies.) Graph subscriptions are explicitly not indefinite — the docs state apps "need to renew their subscriptions before the expiration time; Otherwise, they need to create a new subscription." — Set up notifications for changes in resource data — Subscription lifetime, learn.microsoft.com - Renewal:
PATCH https://graph.microsoft.com/v1.0/subscriptions/{id}with a JSON body containing only the newexpirationDateTime(must still respect the same per-resource max-duration ceiling from the point above — a single renewal cannot push the expiry further than ~7 days out from the renewal call). A successful renewal returns200 OKwith the updated subscription object. Each change notification also carries the currentsubscriptionExpirationDateTime, which the docs recommend using as a cue for when to renew. Optionally, alifecycleNotificationUrlcan be registered alongsidenotificationUrlto receive an explicit "subscription about to expire" / "reauthorization required" push rather than relying solely on tracking dates client-side. — Receive change notifications through webhooks — Subscription lifecycle / Renew a subscription, learn.microsoft.com
Recommended approach
For issue #5 ("Cache refresh trigger mechanism"), use two different strategies per backend, since only one of the two sources has a usable push mechanism:
- Nextcloud/Mail-in-a-Box (CalDAV): poll with WebDAV Collection Sync (RFC 6578 sync-token), not webhooks. Mail-in-a-Box's pinned Nextcloud (27.1.11) predates the
webhook_listenersapp (Nextcloud ≥30) entirely, so no webhook path exists on this backend without the household upgrading Nextcloud out-of-band from Mail-in-a-Box's own installer — not something to build a dependency on. Implement the Symfony CalDAV client to store each calendar's sync-token, issue a periodicsync-collectionREPORT (e.g. on the cache-refresh job's normal cadence), and only re-parse/update the SQLite cache for the returned deltas rather than re-fetching the whole calendar every cycle. - Microsoft Graph: use webhook change notifications (
/subscriptionson/me/events), since Graph explicitly supports it end-to-end for both personal and work/school accounts. Implementation shape:- Symfony backend exposes a public HTTPS
notificationUrlendpoint that (a) handles the synchronousvalidationTokenhandshake (echo it back astext/plain,200 OK, within 10s) and (b) accepts subsequentPOSTchange-notification payloads, checksclientState, and — since notifications are basic (no event data) — triggers a re-fetch of the changed event/calendar into the SQLite cache. - Create the subscription with
changeType: "created,updated,deleted",resource: "/me/events", and anexpirationDateTimeno more than ~7 days out (10,080-minute cap). - Renewal is mandatory, not optional — schedule a recurring job (well inside the 7-day window, e.g. daily) that
PATCHes the subscription with a freshexpirationDateTime; a lapsed subscription silently stops delivering notifications. TracksubscriptionExpirationDateTimefrom incoming notifications, or subscribe tolifecycleNotificationUrlevents, as a second signal so a missed renewal is self-detected rather than silently going stale. - Keep a low-frequency polling fallback even with the webhook wired up — Graph notifications can be delayed, dropped after throttling, or silently lapse if renewal fails; a periodic full-resync (e.g. once daily) against
/me/eventsguards against any single missed/expired subscription leaving the dashboard's cache stale indefinitely.
- Symfony backend exposes a public HTTPS