Choose each address book, calendar, task list and folder from the server by href, with a mode each, and hold every request to them #13

Open
opened 2026-09-30 20:30:04 +00:00 by tiagoagueda · 1 comment
Owner

This asks that the plugin "can only read and/or write the desired / specified calendar(s), contact list(s), task(s), files(s), folder(s), etc." Today a connection names one address book and one calendar by display name (default Postulo, src/postulo_dav/sync.py:250-273), takes the first collection so named (src/postulo_dav/dav.py:173-178) and makes a missing one unless Read only is on (dav.py:180-200).

Read here: "read and/or write" as a mode per chosen collection; "task(s)" and "files(s)" as their lists and folders (see Decisions).

What is wrong today

  • Credentials follow any href. etags() joins every multistatus href with a plain urljoin, checking neither that it is under the collection nor its origin (dav.py:104-105, 204-221), as discovery does for principal and home (dav.py:136, 149); every request carries the Basic-auth credentials (dav.py:107-108). An address book's unlinked href is fetched and adopted (sync.py:528-549), then written and deleted.
  • A typed name hands over a personal book. Name Contacts: every card becomes a contact, every contact is pushed there (sync.py:425-457, 527-550), though the help text says "Only this address book is touched".
  • A name is not an identity. When the name comes to match another collection, every link is flagged removed and never pushed again (sync.py:433-434, 458-466, 569-570, 589-601), and interviews gain false "removed from the calendar" entries. A collection the plugin made and someone renamed fares worse: the next run sends MKCOL to its fixed address again, which a server must refuse (RFC 4918 §9.3.1), so every run fails (read from the code; not tried).
  • No switches. A server with no calendar home fails the whole run after writing the contacts, losing their counts (sync.py:396-415; src/postulo/plugins/syncing.py:79-85 in the core).
  • Untested. The fake server's other collections are empty, its log has no host, and assertions filter by method (tests/test_dav.py:48-51, 70).

What the plugin should do

  1. Separate switches for contacts, events and tasks (#12). Files are a store connection (#5) and backups the operator's (#6), each held to its folder by the same guard.
  2. Collections chosen from the server's listing, kept by href. PROPFIND Depth 1 on every href of each home set (home() reads only the first, dav.py:146-149) for displayname, resourcetype, supported-calendar-component-set (absent means all, RFC 4791 §5.2.3), supported-address-data and current-user-privilege-set (RFC 3744 §5.4). Events get calendars accepting VEVENT; tasks, those accepting VTODO. Folders, which have no discovery (RFC 6764), are listed from a given address. A vanished choice is reported, not re-made; making a Postulo one stays possible.
  3. A mode per collection. Writing is offered only with DAV:bind and DAV:write-content (RFC 3744 §3.9, §3.4), never on write-properties, which Nextcloud grants on read-only shares (https://bugzilla.mozilla.org/show_bug.cgi?id=2051561). A folder without ACL (only CalDAV and CardDAV need it) is offered writing, with a note.
  4. One guard in dav.py: an allowed origin, under a chosen collection, a method the mode allows. Excepted: discovery (PROPFIND, Depth 0 or 1, those properties) and making a requested collection. A request hook on the core's client() (event_hooks, src/postulo/plugins/http.py:139-145 in the core) also sees redirects; the core's hook runs first and may swap the host for the approved address, keeping the name in Host (http.py:101-113). Sync runs stop listing the homes.
  5. Links outside the choice are neither pushed nor flagged removed; the report counts them.
  6. The README says the modes are Postulo's word, not the server's lock. App passwords cannot be narrowed to a collection or folder on Nextcloud, Fastmail or iCloud. Real confinement: a Nextcloud account receiving only the wanted shares, Radicale's rights file, a Hetzner Storage Box sub-account (none tried).

Decisions

  • Collections or items. "task(s)" and "files(s)" may mean single items. Recommended: collections here; items belong to #8, #11, #12 and #5.
  • Two modes or three. "and/or" literally includes write only. Recommended: offer it (push only); a server-side edit is overwritten at the next push, with a note.
  • Which origins. iCloud reportedly serves homes from other hosts (not tried). Recommended: the typed origin plus the principal's and homes', https only.
  • Before the core lands. With item 1, one connection per collection works on today's core. Recommended: ship items 1 and 4–6, the tests, an href accepted for a name, Test naming what it found, help text saying a named collection syncs whole, and refusing a name two collections share, at Test and each run (not validate(): it would sign in on every save, unthrottled).

Needs from the core

postulo/postulo#332: the form cannot offer the server's collections (static choices, no multi-select, config_fields() sees no configuration, Test cannot write back). Until then: the last decision.

New strings go through uv run postulo-messages extract, fr-fr and pt-pt filled on the commit (the test gates them), and the 23 EU catalogues before a tag (uv run pytest -m release). New surface names mean repinning the core (pyproject.toml dev group pins commit 1d6cdd7e0) and extending tests/test_surface.py.

Done when

  • A test with Contacts and Personal filled, and hrefs off-host or outside the collection, asserts every logged request (method, host, path).
  • Each component switches off; a server with no calendar home syncs contacts and says so.
  • A server-side rename changes nothing; a collection taken out of the choice gets no request.
  • A read collection gets no PUT, DELETE, MKCOL or MKCALENDAR; a Nextcloud read-only share is not offered writing (unverified).

From the list of DAV plugin improvements of 2026-09-30.

This asks that the plugin "can only read and/or write the desired / specified calendar(s), contact list(s), task(s), files(s), folder(s), etc." Today a connection names one address book and one calendar by display name (default *Postulo*, `src/postulo_dav/sync.py:250-273`), takes the first collection so named (`src/postulo_dav/dav.py:173-178`) and makes a missing one unless *Read only* is on (`dav.py:180-200`). Read here: "read and/or write" as a mode per chosen collection; "task(s)" and "files(s)" as their lists and folders (see Decisions). ## What is wrong today - **Credentials follow any href.** `etags()` joins every multistatus `href` with a plain `urljoin`, checking neither that it is under the collection nor its origin (`dav.py:104-105, 204-221`), as discovery does for principal and home (`dav.py:136, 149`); every request carries the Basic-auth credentials (`dav.py:107-108`). An address book's unlinked href is fetched and adopted (`sync.py:528-549`), then written and deleted. - **A typed name hands over a personal book.** Name *Contacts*: every card becomes a contact, every contact is pushed there (`sync.py:425-457, 527-550`), though the help text says "Only this address book is touched". - **A name is not an identity.** When the name comes to match another collection, every link is flagged removed and never pushed again (`sync.py:433-434, 458-466, 569-570, 589-601`), and interviews gain false "removed from the calendar" entries. A collection the plugin made and someone renamed fares worse: the next run sends `MKCOL` to its fixed address again, which a server must refuse (RFC 4918 §9.3.1), so every run fails (read from the code; not tried). - **No switches.** A server with no calendar home fails the whole run after writing the contacts, losing their counts (`sync.py:396-415`; `src/postulo/plugins/syncing.py:79-85` in the core). - **Untested.** The fake server's other collections are empty, its log has no host, and assertions filter by method (`tests/test_dav.py:48-51, 70`). ## What the plugin should do 1. **Separate switches** for contacts, events and tasks (#12). Files are a store connection (#5) and backups the operator's (#6), each held to its folder by the same guard. 2. **Collections chosen from the server's listing, kept by href.** PROPFIND Depth 1 on every href of each home set (`home()` reads only the first, `dav.py:146-149`) for `displayname`, `resourcetype`, `supported-calendar-component-set` (absent means all, RFC 4791 §5.2.3), `supported-address-data` and `current-user-privilege-set` (RFC 3744 §5.4). Events get calendars accepting VEVENT; tasks, those accepting VTODO. Folders, which have no discovery (RFC 6764), are listed from a given address. A vanished choice is reported, not re-made; making a *Postulo* one stays possible. 3. **A mode per collection.** Writing is offered only with `DAV:bind` and `DAV:write-content` (RFC 3744 §3.9, §3.4), never on `write-properties`, which Nextcloud grants on read-only shares (https://bugzilla.mozilla.org/show_bug.cgi?id=2051561). A folder without ACL (only CalDAV and CardDAV need it) is offered writing, with a note. 4. **One guard** in `dav.py`: an allowed origin, under a chosen collection, a method the mode allows. Excepted: discovery (PROPFIND, Depth 0 or 1, those properties) and making a requested collection. A request hook on the core's `client()` (`event_hooks`, `src/postulo/plugins/http.py:139-145` in the core) also sees redirects; the core's hook runs first and may swap the host for the approved address, keeping the name in `Host` (`http.py:101-113`). Sync runs stop listing the homes. 5. **Links outside the choice** are neither pushed nor flagged removed; the report counts them. 6. **The README says** the modes are Postulo's word, not the server's lock. App passwords cannot be narrowed to a collection or folder on Nextcloud, Fastmail or iCloud. Real confinement: a Nextcloud account receiving only the wanted shares, Radicale's rights file, a Hetzner Storage Box sub-account (none tried). ## Decisions - **Collections or items.** "task(s)" and "files(s)" may mean single items. Recommended: collections here; items belong to #8, #11, #12 and #5. - **Two modes or three.** "and/or" literally includes write only. Recommended: offer it (push only); a server-side edit is overwritten at the next push, with a note. - **Which origins.** iCloud reportedly serves homes from other hosts (not tried). Recommended: the typed origin plus the principal's and homes', https only. - **Before the core lands.** With item 1, one connection per collection works on today's core. Recommended: ship items 1 and 4–6, the tests, an href accepted for a name, *Test* naming what it found, help text saying a named collection syncs whole, and refusing a name two collections share, at *Test* and each run (not `validate()`: it would sign in on every save, unthrottled). ## Needs from the core postulo/postulo#332: the form cannot offer the server's collections (static choices, no multi-select, `config_fields()` sees no configuration, *Test* cannot write back). Until then: the last decision. New strings go through `uv run postulo-messages extract`, fr-fr and pt-pt filled on the commit (the test gates them), and the 23 EU catalogues before a tag (`uv run pytest -m release`). New surface names mean repinning the core (`pyproject.toml` dev group pins commit `1d6cdd7e0`) and extending `tests/test_surface.py`. ## Done when - A test with *Contacts* and *Personal* filled, and hrefs off-host or outside the collection, asserts every logged request (method, host, path). - Each component switches off; a server with no calendar home syncs contacts and says so. - A server-side rename changes nothing; a collection taken out of the choice gets no request. - A read collection gets no PUT, DELETE, MKCOL or MKCALENDAR; a Nextcloud read-only share is not offered writing (unverified). From the list of DAV plugin improvements of 2026-09-30.
Author
Owner

From the efficiency and reliability audit of 2026-10-30 (finding PR4), what this issue does not yet say:

On the forge's main (83710e9), src/postulo_dav/sync.py:623-634 _collection runs for each half of every run (sync.py:638-676): Server.principal (dav.py:252-274, a Depth 0 PROPFIND on the typed address, then /.well-known/…, then /, stopping at the first that answers), Server.home (dav.py:276-289, one Depth 0 PROPFIND), and find_collection → collections (dav.py:291-321, one Depth 1 PROPFIND of the home). So an idle run against a server that answers at the typed address makes 3 discovery requests per half, 6 in all, before the 2 getetag listings that are the actual work.

Two of the six are identical: the principal probe of the address as typed is the same request for both halves (principal("carddav") and principal("caldav") both try "" first), and home() sends _HOMES (dav.py:60-65), which already asks for both the address-book and the calendar home set in one body, twice.

postulo-dav#13 (open, "Choose each address book, calendar, task list and folder from the server by href…") covers the main point: item 2 keeps each chosen collection by href in the connection, and item 4 says "Sync runs stop listing the homes." It waits on core #332 (a form that offers options found on the server). The PR2 core proposal (a per-connection sync state) would be another route, but #13 already removes the need for it here.

Nothing new to file. Add a comment to postulo-dav#13 saying that, until its collections-by-href lands, Server can remember the principal and the _HOMES answer for the length of one run (a dictionary on the instance, which lives for one run), taking an idle run from 8 requests to 6 without any stored state. Once #13 lands, a run is the two listings plus whatever changed.

Checked against main at daee30b15 (2026-10-30).

From the efficiency and reliability audit of 2026-10-30 (finding PR4), what this issue does not yet say: On the forge's `main` (`83710e9`), `src/postulo_dav/sync.py:623-634` `_collection` runs for each half of every run (`sync.py:638-676`): `Server.principal` (`dav.py:252-274`, a Depth 0 `PROPFIND` on the typed address, then `/.well-known/…`, then `/`, stopping at the first that answers), `Server.home` (`dav.py:276-289`, one Depth 0 `PROPFIND`), and `find_collection` → `collections` (`dav.py:291-321`, one Depth 1 `PROPFIND` of the home). So an idle run against a server that answers at the typed address makes 3 discovery requests per half, 6 in all, before the 2 `getetag` listings that are the actual work. Two of the six are identical: the principal probe of the address as typed is the same request for both halves (`principal("carddav")` and `principal("caldav")` both try `""` first), and `home()` sends `_HOMES` (`dav.py:60-65`), which already asks for both the address-book and the calendar home set in one body, twice. postulo-dav#13 (open, "Choose each address book, calendar, task list and folder from the server by href…") covers the main point: item 2 keeps each chosen collection by href in the connection, and item 4 says "Sync runs stop listing the homes." It waits on core #332 (a form that offers options found on the server). The PR2 core proposal (a per-connection sync state) would be another route, but #13 already removes the need for it here. Nothing new to file. Add a comment to postulo-dav#13 saying that, until its collections-by-href lands, `Server` can remember the principal and the `_HOMES` answer for the length of one run (a dictionary on the instance, which lives for one run), taking an idle run from 8 requests to 6 without any stored state. Once #13 lands, a run is the two listings plus whatever changed. Checked against `main` at `daee30b15` (2026-10-30).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-dav#13
No description provided.