CalDAV, CardDAV, and discovery¶
This is the practical note for the first Gulo Gulo calendar, contacts, and automatic-configuration contracts. It is deliberately a little more concrete than a protocol overview: these modules are the boundary that a real DAV HTTP adapter must honour. They do not quietly become a second mail store, and they do not give the master a back door into a user's content.
What is in the repository¶
| Area | Source | What it owns |
|---|---|---|
| CalDAV | src/core/dav/caldav/caldav-contract.ts |
iCalendar validation, collections, ACL, ETags, conditional writes, sync tokens, tombstones, metadata-only audit events |
| CalDAV PostgreSQL adapter | src/core/dav/caldav/postgres-caldav-store.ts |
persistent storage for the same CalDAV operations, backed by 0003_dav_storage.sql |
| CardDAV | src/core/dav/carddav/carddav-store.ts |
vCard validation, address books, ETags, conditional writes, sync tokens, tombstones, metadata exports |
| CardDAV PostgreSQL adapter | src/core/dav/carddav/postgres-carddav-store.ts |
persistent storage for the same CardDAV operations, backed by 0003_dav_storage.sql |
| CardDAV exports | src/core/dav/carddav/index.ts |
stable public module aliases for future adapters |
| DAV storage migration | src/core/db/migrations/0003_dav_storage.sql |
dav_calendar_collections/dav_calendar_objects/dav_calendar_changes, dav_address_books/dav_contacts/dav_contact_changes, tenant-forced RLS |
| Discovery | src/core/dav/discovery/index.ts |
tenant-bound .well-known resources, mail autoconfig, service endpoints, safe manual overrides |
| HTTP well-known | src/runtime/server.ts |
optional, explicitly injected discovery contract for GET/HEAD resources |
| HTTP DAV surface | src/runtime/server.ts (handleDavRoute()), tested by src/runtime/dav-runtime.test.ts |
PROPFIND/GET/PUT/DELETE/REPORT under /dav/calendars/*//dav/contacts/*, session-authenticated, calling runtime.davStore |
| Platform wiring | src/platform/contract/platform-adapter.ts (createDavStore()), and the standalone/cPanel/Plesk adapters; resolved into the running server by src/runtime/index.ts |
resolves the PostgreSQL-backed CalDAV/CardDAV stores for each packaging target |
| Browser surface | web/index.html, web/src/app.ts |
read-only Calendar and Contacts views, discovery status, and manual-fallback messaging |
caldav-contract.ts and carddav-store.ts stay deterministic, synchronous,
in-memory contract doubles — every method there returns a plain value, never
a Promise, and their own tests call them without await. That is
deliberate: it keeps the pure validation/authorization/ETag/sync-token logic
free of I/O and trivially testable. postgres-caldav-store.ts and
postgres-carddav-store.ts are a separate, asynchronous implementation
of the same operations, backed by PostgreSQL instead of a Map — genuine
network I/O cannot be added to a synchronous public method without breaking
that contract's own tests, so the two live side by side rather than one
being "injected into" the other. To keep them from silently drifting on
anything a real DAV client observes on the wire, the PostgreSQL adapters
import the pure contracts' own exported ETag (calculateEtag/
collectionEtag/makeEtag/makeCollectionEtag), sync-token
(makeToken/decodeToken/makeSyncToken/parseSyncToken), and scope/id
validation functions rather than reimplementing them — an ETag or sync token
computed by the PostgreSQL adapter is byte-for-byte identical to what the
in-memory contract would compute for the same tenant/collection/object/
content. In production the PostgreSQL adapter is the source of truth for
calendars, events, address books, vCards, ETags, sync history, and
collection metadata — nothing else becomes a second mutable copy of those
objects.
Scope and authorization¶
Every operation receives an authenticated scope. The scope is not inferred
from a user-controlled path, query string, tenantId field, or object body.
CalDAV actors have the shape:
{ tenantId: 'acme', userId: 'alice', role: 'user' }
The CalDAV contract rejects provider, tenant-master, and monitoring roles for
content operations with CONTENT_SCOPE_REQUIRED. CardDAV rejects the same
roles with SCOPE_ROLE_DENIED. An administrator must obtain an explicitly
delegated user scope; passing targetUserId or an owner path is not a
delegation mechanism.
The V1 sharing rule is intentionally narrow: an owner can have at most one
active delegate. Permissions are read and write; write always includes
read. ACL changes are owner-only and are suitable for audit logging. A
delegated session is still distinguishable from the owner in the audit event.
CalDAV contract¶
Create a store for one tenant and use it from a verified user context:
import { createCalDavStore } from './src/core/dav/caldav/caldav-contract.ts';
const store = createCalDavStore({ tenantId: 'acme' });
const alice = { tenantId: 'acme', userId: 'alice', role: 'user' };
store.createCalendarCollection(alice, {
collectionId: 'personal',
displayName: 'Alice calendar',
timezone: 'Europe/Rome',
});
const created = store.createCalendarObject(alice, {
calendarId: 'alice/personal',
objectId: 'event-1.ics',
ifNoneMatch: '*',
ical: '...canonical iCalendar text...',
});
store.updateCalendarObject(alice, {
calendarId: 'alice/personal',
objectId: 'event-1.ics',
ifMatch: created.etag,
ical: '...same UID, changed properties... ',
});
The contract maps naturally to the usual DAV method set:
| HTTP method | Contract operation | Important rule |
|---|---|---|
OPTIONS |
adapter capability response | advertise only methods actually wired by the adapter |
PROPFIND |
list/get collection metadata | never enumerate another user's collection |
REPORT |
listCalendarObjects with a sync token |
token is tenant, owner, and collection scoped |
MKCOL |
createCalendarCollection |
owner creates only their own collection |
GET |
getCalendarObject or collection metadata |
read ACL is checked first |
PUT |
create/update object | create uses If-None-Match: *; update requires If-Match |
DELETE |
delete object/empty collection | object deletion requires If-Match; non-empty collections are not dropped silently |
src/runtime/server.ts's handleDavRoute() is that protocol adapter — see
"HTTP surface" further down for its exact URL structure, method coverage,
and authentication/authorization rules. It authenticates the request from
the same session cookie as /api/*, builds the actor/scope from that
session, applies (a minimal, documented subset of) the table above, and
calls the store; it never calls a store with a role elevated from an HTTP
header or a path segment.
iCalendar input¶
validateICalendar() accepts a bounded, interoperable subset:
- one closed
VCALENDARwith exactly oneVEVENT; VERSION:2.0,PRODID,UID,DTSTART, and optionalDTENDorDURATION;VTIMEZONEwithSTANDARD/DAYLIGHTtransitions;- UTC, explicit
TZID, floating local time, and date-only values; - attendees, organizer, method, sequence, summary, description, and alarms at the supported level;
- CRLF canonical output, folded-property unfolding, control-character checks, size, line, component-count, and nesting limits.
Floating and date-only values are retained as floating/date-only values. They
are not silently converted to UTC, which would change their meaning around DST
boundaries. A TZID used by an event must have a matching VTIMEZONE definition
in the object; undefined zones fail closed.
ETags are opaque SHA-256 values over the tenant, collection, object id, and canonical object. A stale ETag returns a precondition failure and never overwrites the newer object. Each successful mutation creates a change record and advances the collection sync token atomically in the contract double. Deleted objects appear as tombstones in a subsequent sync result.
CardDAV contract¶
CardDAV uses the same user/tenant boundary, with address-book collections and single-object vCards:
import { createCardDAVStore } from './src/core/dav/carddav/carddav-store.ts';
const store = createCardDAVStore();
const scope = { tenantId: 'acme', userId: 'alice', role: 'user' };
store.createAddressBook({
scope,
addressBookId: 'personal',
displayName: 'Alice contacts',
});
const contact = store.createContact({
scope,
addressBookId: 'personal',
href: 'ada.vcf',
ifNoneMatch: '*',
vCard: 'BEGIN:VCARD\\r\\nVERSION:4.0\\r\\nUID:ada\\r\\nFN:Ada Lovelace\\r\\nEND:VCARD\\r\\n',
});
store.putContact({
scope,
addressBookId: 'personal',
href: 'ada.vcf',
ifMatch: contact.etag,
vCard: '...same UID, changed properties...',
});
Both vCard 3.0 and 4.0 are accepted. The validator canonicalizes line endings
to CRLF, unfolds safe folded lines, requires exactly one UID and FN,
rejects NUL/control data, and keeps bounded object and line sizes. The UID is
immutable for an existing href. ETags are opaque hashes of the canonical
vCard. syncCollection() returns metadata changes and deletion tombstones;
it does not put vCard bodies into a sync event.
exportAddressBookMetadata() is deliberately metadata-only. A user export
adapter may fetch the actual vCard objects after a fresh authorization check;
the master and a monitoring role cannot turn this method into a privileged
export.
Persistent PostgreSQL backend¶
createPostgresCalDavStore()/createPostgresCardDavStore() implement the
same operations as the pure contracts above, asynchronously, against the
tables in src/core/db/migrations/0003_dav_storage.sql. They reuse
createPostgresStore() (src/integrations/postgres-store.ts) for the
connection pool, retry, SSL, and RLS transaction plumbing — the same pattern
src/platform/standalone/db-identity-client.ts already uses for
local_users — so withTenantTransaction() sets the gulogulo.tenant_id
RLS context per request and every table carries a FORCE ROW LEVEL SECURITY
tenant-isolation policy.
import { createPostgresCalDavStore } from './src/core/dav/caldav/postgres-caldav-store.ts';
const store = createPostgresCalDavStore({ config, resolveSecret, logger });
if (store.enabled) {
// actor extends the pure contract's {tenantId, userId, role} shape with a
// `domain`, required because withTenantTransaction() needs a full
// TenantContext (see src/integrations/tenant-context.ts).
const alice = { tenantId: 'acme', domain: 'acme.example', userId: 'alice', role: 'user' };
await store.createCalendarCollection(alice, { collectionId: 'personal' });
}
Schema shape, one row per object:
dav_calendar_collections/dav_address_books— one row per calendar or address book, owner/user-scoped, with arevision bigintsync-token counter and (CalDAV only) a single-delegateacl_delegate_user_id/acl_permissionspair, matching the V1 one-delegate sharing rule.dav_calendar_objects/dav_contacts— one row per iCalendar object or vCard, storing the canonical text, ETag, and UID.dav_contactshas aUNIQUE (tenant_id, user_id, address_book_id, uid)constraint (CardDAV checks UID uniqueness);dav_calendar_objectsdeliberately does not (the pure CalDAV contract never checks it either).dav_calendar_changes/dav_contact_changes— the changelog/tombstone table behind the sync-token report: every create/update/delete appends a row here instead of soft-deleting the object row, matching the in-memory contracts' separate in-memorychangesarray.
The sync-token revision is a per-collection counter, bumped with
UPDATE ... RETURNING on the collection/address-book row so the bump and
the resulting change row commit under that row's lock (the in-memory
contract uses one global-per-tenant-store counter instead; nothing in either
public contract compares revisions across collections, so this is a safe,
non-observable implementation difference). deleteCalendarCollection()
still refuses a non-empty collection (CALENDAR_NOT_EMPTY, 409) by counting
objects before deleting, matching the pure contract; deleteAddressBook()
does not — the pure CardDAV contract never refuses either, it just drops the
whole collection, which here is an ON DELETE CASCADE from
dav_contacts/dav_contact_changes.
putContact()'s idempotent no-op — an unchanged vCard returns the existing
contact without bumping the revision or appending a change row — is
preserved: the adapter recomputes the ETag before writing and short-circuits
on a match, same as CardDavStore#putContact().
PlatformAdapter.createDavStore(config) (src/platform/contract/platform-adapter.ts)
wires this into all three packaging targets
(src/platform/standalone/standalone-adapter.ts,
src/platform/cpanel/cpanel-adapter.ts, src/platform/plesk/plesk-adapter.ts),
each building its own pool from the same PostgreSQL connection settings
createDataStore() already uses.
Status: DONE (code + tests against a fake pool, including the HTTP surface below) / VERIFY BEFORE USE (a real PostgreSQL instance, and a real CalDAV/CardDAV client). See "HTTP surface" below for what now actually calls the PostgreSQL store.
HTTP surface¶
src/runtime/server.ts routes a minimal, interoperable WebDAV method/report
subset directly to runtime.davStore (PlatformAdapter.createDavStore(),
injected — see "Wiring" below). It does not implement the full RFC
4791/6352 method set: there is no MKCOL/MKCALENDAR, PROPPATCH, COPY,
MOVE, LOCK/UNLOCK, or ACL over HTTP. A calendar or address book must
already exist — created directly against the contract or PostgreSQL adapter
above, e.g. during provisioning — before a client can address it here.
URL structure¶
Reuses exactly the href convention postgres-caldav-store.ts already builds
server-side (createCalendarCollection()'s href), and mirrors it for
CardDAV (which has no fixed HTTP convention of its own — its stored href
metadata field, /addressbooks/{id}/, is internal bookkeeping the router
does not use for addressing):
- Calendar collection:
/dav/calendars/{tenantId}/{ownerUserId}/{collectionId}/ - Calendar object:
/dav/calendars/{tenantId}/{ownerUserId}/{collectionId}/{objectId} - Address book collection:
/dav/contacts/{tenantId}/{userId}/{addressBookId}/ - Contact object:
/dav/contacts/{tenantId}/{userId}/{addressBookId}/{href}(hrefalready ends in.vcf)
Both share the /dav/ base path .well-known/caldav/.well-known/carddav
already redirect to (src/core/dav/discovery/index.ts's defaultPath: '/dav/'
for both services) — this router is what that redirect now actually lands on.
There is deliberately no calendar-home-set/principal discovery: a client
must already know its {tenantId}/{ownerUserId}/{collectionId} (or
{tenantId}/{userId}/{addressBookId}) triple. Real clients normally
discover that by PROPFIND-ing the principal URL and then the
calendar-home-set/addressbook-home-set property; neither is implemented,
so this is real, outstanding interoperability work (see the VERIFY BEFORE
USE note at the end of this document).
Methods¶
| Method | Level | What it does | What it deliberately does not do |
|---|---|---|---|
PROPFIND |
collection (Depth 0/1) | 207 Multi-Status with resourcetype, displayname, getlastmodified, sync-token for the collection, plus one entry per object at Depth 1 (getetag, getcontenttype, empty resourcetype) |
ignores the requested <D:prop> selection and always returns this fixed set; Depth: infinity is rejected with 403 |
PROPFIND |
object (Depth 0) | 207 with that object's getetag/getcontenttype |
same fixed-property-set limitation |
GET/HEAD |
object | the raw text/calendar/text/vcard body with an ETag header |
no GET on a collection (there is nothing meaningful to serve; 404) |
PUT |
object | creates (If-None-Match: *, or no conditional header for CalDAV — CardDAV always requires If-None-Match: * to create, matching carddav-store.ts) or updates (If-Match, mapped straight to the contract's own conditional semantics) |
no unconditional overwrite of an existing object without If-Match (428) |
DELETE |
object | deletes one calendar object/contact, If-Match required (428 if missing, 412 if stale) |
— |
DELETE |
collection | deletes an empty calendar collection (409 if it still has objects) or an address book (cascades, matching carddav-store.ts's own no-not-empty-guard) |
— |
REPORT |
collection | calendar-query/addressbook-query (full collection snapshot — no date-range or property filtering) and sync-collection (incremental changes plus 404 Not Found tombstones for deletions, terminated with a fresh <D:sync-token>) |
no calendar-multiget/addressbook-multiget; no <C:calendar-data>/<CARD:address-data> embedded in query results (fetch the body with GET) |
Any other method (MKCOL, PROPPATCH, COPY, MOVE, LOCK, ACL, ...)
answers 501 Not Implemented rather than being silently ignored, per the
existing fail-closed style of this codebase. An unrecognized REPORT body
also answers 501. Every contract/store error (CalDavError/CardDavError,
already carrying the right status/code — see the "Conditional rules"
table above) is caught once, centrally, and mapped straight through to the
matching HTTP status; nothing here re-derives status codes from error
messages.
Authentication and authorization¶
DAV routes authenticate with the exact same cookie session as every other
/api/* route (runtime.webSecurity.authenticate()); there is no separate
DAV credential path, Basic/Digest auth, or app-password mechanism. An
unauthenticated request gets 401, identical to /api/mail/messages etc.
Authorization never trusts the URL: the authenticated session's tenantId
must equal the URL's tenantId segment (checked by the router itself,
before any store call — a mismatch is 403, and the store is never even
queried), and every store call is made with an actor/scope built from the
session ({tenantId, domain, userId, role}), never from the URL. CardDAV
has no delegate/sharing concept in this codebase, so its URL's userId
segment must equal the session's userId (checked the same way, 403 on
mismatch); CalDAV's ownerUserId segment may legitimately differ from the
session user for a delegated calendar — resolveCollectionRow()'s own
owner-vs-delegate ACL check decides that (403 ACL_DENIED if the session
user has neither ownership nor a delegate grant), not the router.
PUT/DELETE/REPORT do not require the X-CSRF-Token header that
POST /api/session/logout uses: a real DAV client (Apple Calendar,
Thunderbird, DAVx5, ...) cannot obtain or send that double-submit token, so
this surface relies on the session cookie's own SameSite=Lax/Strict
attribute (src/web/security/session-manager.ts) as its cross-site request
forgery mitigation instead. This is a deliberate, documented trade-off.
Wiring¶
runtime.davStore ({ caldav, carddav }, the same shape
PlatformAdapter.createDavStore() already returns) is an injected,
optional createRuntimeServer() option; when omitted, every /dav/*
request answers 503 DAV_STORE_UNAVAILABLE instead of touching anything.
src/runtime/index.ts's main() resolves it the same way it already
resolves the login authenticator — resolvePlatformTarget() picks the
GULOGULO_PLATFORM target, createPlatformAdapterForTarget() builds the
matching adapter, adapter.createDavStore(config) returns the pair — and a
resolution failure (misconfigured/unreachable PostgreSQL) is caught, logged,
and leaves /dav/* at 503 instead of crashing server startup.
Tests¶
src/runtime/dav-runtime.test.ts drives the full HTTP surface end to end —
real HTTP requests over a loopback socket, real
createPostgresCalDavStore()/createPostgresCardDavStore() adapters, and
the same fake-pool pattern as postgres-caldav-store.test.ts/
postgres-carddav-store.test.ts (not the in-memory contract doubles, and
not a real PostgreSQL instance). It covers: PUT create with
If-None-Match: * and its 412 conflict on retry; GET reading the body
and ETag back; conditional PUT update (412 on a stale If-Match,
204 + a new ETag on success); PROPFIND at Depth 0 and 1; REPORT
calendar-query; REPORT sync-collection across create/update/delete with
tombstones; conditional DELETE; a cross-tenant CalDAV URL (403, store
never reached); a cross-user CardDAV URL (403); an unsupported method
(501); and an unauthenticated request (401) — plus the equivalent
CardDAV PUT/GET/DELETE/REPORT sync-collection cycle.
Sync tokens and conditional writes¶
CalDAV tokens encode an opaque HTTPS-shaped token bound to tenant, owner, and collection. CardDAV tokens use a non-enumerable scoped fingerprint bound to tenant, user, and address book. Neither token is accepted for another scope, another tenant, or a future revision. A persistent adapter must invalidate tokens after restore, compaction, or loss of change history and force a full resynchronization.
Conditional rules are intentionally strict:
- create:
If-None-Match: *; - update: current
If-Match(or an explicitly supported wildcard); - delete: current
If-Match; - stale preconditions:
412; - missing update/delete preconditions:
428; - malformed input: bounded
4xxerror with no object content in the message.
Discovery and manual fallback¶
Build a tenant-bound discovery contract with public HTTPS hosts only:
import {
createDiscoveryContract,
getWellKnownResource,
WELL_KNOWN_PATHS,
} from './src/core/dav/discovery/index.ts';
const discovery = createDiscoveryContract({
tenantId: 'acme',
domain: 'example.test',
origin: 'https://example.test',
manualOverrides: {
smtp: { host: 'submit.example.test', port: 587, tlsMode: 'starttls' },
},
});
const resource = getWellKnownResource(discovery, WELL_KNOWN_PATHS.autoconfig, {
tenantId: 'acme',
});
The contract publishes:
/.well-known/caldavand/.well-known/carddavas HTTPS308redirects;/.well-known/autoconfig/mail/config-v1.1.xmlfor IMAP and SMTP;/.well-known/gulogulo/discovery.jsonfor enabled service metadata.
IMAP, SMTP submission, CalDAV, CardDAV, and optional POP3S are represented.
POP3S is disabled by default and an override cannot re-enable a disabled
service. The payload contains host, port, TLS mode, path, and the {email}
username placeholder, but no password, token, tenant id, internal hostname, or
secret.
The validator rejects private/reserved/metadata hosts, credentials in URLs,
plaintext TLS, unsafe paths, double-encoding indicators, and untrusted origin
hosts. It never takes the HTTP Host header as a tenant decision. When
automatic discovery fails, the UI keeps a manual host/port/TLS/username path
visible and explains that it is a fallback rather than guessing.
The runtime serves these resources only when a validated contract and an
explicit tenant context are injected into createRuntimeServer(). Without
that injection the paths return 404; this fail-closed default prevents an
unconfigured container from publishing internal service names.
Browser and API boundaries¶
The Calendar and Contacts panels use same-origin application API paths and
never connect directly to LDAP, PostgreSQL, DAV storage, or discovery hosts.
Realtime calendar.changed and contacts.changed events contain metadata only
and trigger a refresh; they do not carry event or contact bodies. API and MCP
remain read-only monitoring surfaces. They may report collection counts,
adapter health, sync health, discovery health, and storage usage, but they do
not create, modify, delete, ACL-share, or export DAV content.
Tests and local workflow¶
Run the focused checks when changing these modules:
node --experimental-strip-types src/core/dav/caldav/caldav-contract.test.ts
node --experimental-strip-types src/core/dav/caldav/postgres-caldav-store.test.ts
node --experimental-strip-types src/core/dav/carddav/carddav-store.test.ts
node --experimental-strip-types src/core/dav/carddav/postgres-carddav-store.test.ts
node --experimental-strip-types src/core/dav/discovery/index.test.ts
node --experimental-strip-types --test src/platform/standalone/*.test.ts src/platform/cpanel/*.test.ts src/platform/plesk/*.test.ts
node dist/server/src/runtime/runtime.test.js
node dist/server/src/runtime/dav-runtime.test.js
npm run build:web
node --experimental-strip-types web/test/web-shell.test.ts
The postgres-*-store.test.ts files exercise the PostgreSQL adapters against
a fake pool (same FakePool/FakeClient pattern as
src/integrations/postgres-store.test.ts), including a test that asserts the
PostgreSQL-computed ETag is byte-identical to what the in-memory contract
computes for the same input. src/runtime/dav-runtime.test.ts exercises the
HTTP surface on top of that same fake-pool pattern, over real loopback HTTP
requests. There is no real PostgreSQL instance or real DAV client anywhere
in this test suite.
npm test includes all of the above contract tests. The GitHub quality gate
(.github/workflows/quality-gates.yml) also checks the explicit repository
entry points, MIT/SPDX headers, and tenant-scope denial markers, on an Ubuntu
ubuntu-latest runner. There is no Docker build or Compose gate in the
current packaging model; the standalone, cPanel, and Plesk package workflows
(.github/workflows/package-{standalone,cpanel,plesk}.yml) are the
authoritative install/build checks — see ../INSTALL.md.
What is deliberately still ahead¶
This milestone establishes the security and data contract, not a claim that every vendor client has already been rehearsed. Before production readiness we still need:
- ~~a persistent DAV backend and transaction/locking adapter~~ — done in
code/tests (
postgres-caldav-store.ts/postgres-carddav-store.tsabove); verification against a real PostgreSQL instance is still outstanding; - ~~an authenticated HTTP adapter for the minimal DAV method set and XML
reports~~ — done in code/tests (see "HTTP surface" above);
MKCOL/MKCALENDAR,PROPPATCH,COPY/MOVE,LOCK/UNLOCK,ACL, and calendar-home-set/principal discovery remain unimplemented, and verification against a real PostgreSQL instance is still outstanding; - ~~real session integration for DAV authentication~~ — done:
/dav/*authenticates with the same cookie session as/api/*(see "HTTP surface" above); real LDAP/DB-backed login itself is covered bysrc/runtime/login.ts(doc/identity-and-postgres.md), unrelated to this DAV-specific wiring; - Apple, Thunderbird, Evolution, and mobile-client interoperability tests
— the HTTP method/report subset implemented here has not been rehearsed
against any real client, and several things a real client commonly
expects are known-missing: calendar-home-set/principal discovery,
calendar-multiget/addressbook-multiget, and<C:calendar-data>/<CARD:address-data>embedded in query results; - quota reservation/rollback and rate limits around DAV uploads and reports
(the shared
davabuse-rate-limit channel now covers/dav/*requests too, but there is still no per-object-size quota accounting); - restore/token-invalidation tests on the external DAV volume.
Shared mailboxes and resource calendars remain future considerations, as specified by the canonical document. They must not be enabled by treating a master or monitoring role as a content owner.