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
37 lines
11 KiB
Markdown
37 lines
11 KiB
Markdown
# 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/mailinabox `setup/nextcloud.sh`, GitHub](https://github.com/mail-in-a-box/mailinabox/blob/main/setup/nextcloud.sh)
|
||
- **Nextcloud's own "Webhooks" capability (`webhook_listeners` app) only exists from Nextcloud 30.0.0 onward.** It ships as an app inside the `nextcloud/server` monorepo itself (`apps/webhook_listeners/`, added by [PR #45475](https://github.com/nextcloud/server/pull/45475)) — it is not a separately-maintained GitHub repo (`nextcloud/webhook_listeners` does not exist as an independent project; the app lives at [`nextcloud/server/apps/webhook_listeners`](https://github.com/nextcloud/server/tree/master/apps/webhook_listeners)). — [nextcloud/server PR #45475, GitHub](https://github.com/nextcloud/server/pull/45475), [nextcloud/server `apps/webhook_listeners`, GitHub](https://github.com/nextcloud/server/tree/master/apps/webhook_listeners)
|
||
- **Consequence: Mail-in-a-Box's Nextcloud 27.1.11 predates `webhook_listeners` by 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_listeners` does 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 run `occ 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](https://docs.nextcloud.com/server/stable/admin_manual/webhook_listeners/index.html)
|
||
- **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-collection` REPORT; 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](https://datatracker.ietf.org/doc/html/rfc6578)
|
||
- **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-collection` REPORT 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 `/subscriptions` API explicitly supports the `event` resource.** The Outlook `event` resource is listed among subscribable resources with resource path `/me/events` / `/users/{id}/events`, supporting both delegated (personal Microsoft account, via `Calendars.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](https://learn.microsoft.com/en-us/graph/api/subscription-post-subscriptions?view=graph-rest-1.0), [Set up notifications for changes in resource data — Supported resources, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/change-notifications-overview#supported-resources)
|
||
- **Creating a subscription**: `POST https://graph.microsoft.com/v1.0/subscriptions` with a JSON body containing `changeType` (`created`, `updated`, `deleted`, comma-separated as needed), `notificationUrl` (the app's public HTTPS webhook endpoint), `resource` (e.g. `/me/events`), `expirationDateTime`, and a required `clientState` secret the app later uses to confirm notifications genuinely came from Graph. A successful call returns `201 Created` with the subscription object including its `id`. — [Create subscription, Microsoft Graph v1.0, learn.microsoft.com](https://learn.microsoft.com/en-us/graph/api/subscription-post-subscriptions?view=graph-rest-1.0)
|
||
- **Validation-token handshake (mandatory, happens synchronously during subscription creation)**: Graph sends `POST https://{notificationUrl}?validationToken={opaqueToken}` with `Content-Type: text/plain`. The app's endpoint must URL-decode the token and respond within **10 seconds** with HTTP `200 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](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks#notificationurl-validation)
|
||
- **Notification payload shape delivered on change**: a `POST` to `notificationUrl` carrying a `changeNotificationCollection` — a JSON object with a `value` array, where each entry has `id`, `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 a `resourceData` block (`@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 single `POST` may bundle multiple notifications across subscriptions. The app must return `2xx` within 3 seconds (`200` if processed inline, `202` if 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](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks)
|
||
- **Maximum subscription duration for `event` (and `message`, `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](https://learn.microsoft.com/en-us/graph/change-notifications-overview#subscription-lifetime)
|
||
- **Renewal**: `PATCH https://graph.microsoft.com/v1.0/subscriptions/{id}` with a JSON body containing only the new `expirationDateTime` (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 returns `200 OK` with the updated subscription object. Each change notification also carries the current `subscriptionExpirationDateTime`, which the docs recommend using as a cue for when to renew. Optionally, a `lifecycleNotificationUrl` can be registered alongside `notificationUrl` to 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](https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks#subscription-lifecycle)
|
||
|
||
---
|
||
|
||
## 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_listeners` app (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 periodic `sync-collection` REPORT (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** (`/subscriptions` on `/me/events`), since Graph explicitly supports it end-to-end for both personal and work/school accounts. Implementation shape:
|
||
1. Symfony backend exposes a public HTTPS `notificationUrl` endpoint that (a) handles the synchronous `validationToken` handshake (echo it back as `text/plain`, `200 OK`, within 10s) and (b) accepts subsequent `POST` change-notification payloads, checks `clientState`, and — since notifications are basic (no event data) — triggers a re-fetch of the changed event/calendar into the SQLite cache.
|
||
2. Create the subscription with `changeType: "created,updated,deleted"`, `resource: "/me/events"`, and an `expirationDateTime` no more than ~7 days out (10,080-minute cap).
|
||
3. **Renewal is mandatory, not optional** — schedule a recurring job (well inside the 7-day window, e.g. daily) that `PATCH`es the subscription with a fresh `expirationDateTime`; a lapsed subscription silently stops delivering notifications. Track `subscriptionExpirationDateTime` from incoming notifications, or subscribe to `lifecycleNotificationUrl` events, as a second signal so a missed renewal is self-detected rather than silently going stale.
|
||
4. 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/events` guards against any single missed/expired subscription leaving the dashboard's cache stale indefinitely.
|