Company contacts in a CardDAV address book and interviews in a CalDAV calendar, in both directions. Nextcloud, Radicale, Baïkal, SOGo, Fastmail and anything else that speaks the standards. https://source.tiagoagueda.com/postulo/postulo
Find a file
Tiago Águeda 97abc97792
Some checks failed
CI / test (push) Failing after 51s
Import one module from Postulo instead of nine
`sync.py` opened with a paragraph explaining why it could not keep the
promise it was asked to keep: a two-way sync needs the records
themselves, the calls that move them so the timeline says so, and the
link table that remembers which twin is whose, and none of that was on
`postulo.plugins.api`. So nine imports reached past it, each marked
`#229`, and the paragraph counted what they risked on a core refactor.

postulo/postulo#229 put them on the surface. The markers go, the
paragraph goes, and the import block is one `from postulo.plugins.api
import (...)`.

`event_text` gets shorter twice over. `event_lines(interview,
alarm=True)` writes the reminder as a VALARM itself, so the
hand-assembled alarm block goes -- with it goes the only use of
`ical.escape`, which is not on the surface and should not be. And
`calendar_status(interview)` answers what `ical.STATUS_OF[outcome]`
answered, so this plugin stops keeping a private copy of a four-row
table that goes stale the day a fifth outcome is added.

`dav.py` took `postulo.plugins` to reach `api.client`; it imports
`client` itself now, which is also what the surface check means by
importing the surface. The fixture that patched the module attribute
patches the name instead.

The inline check in `test_dav.py` -- an AST walk and a
`PAST_THE_SURFACE` set -- is replaced by `tests/test_surface.py`, which
imports the core's `postulo.plugins.testing`. One check rather than a
copy that drifts, and it resolves relative imports, which the copy did
not. It also asserts the sixteen names this sync needs are on `__all__`
rather than merely reachable, because that is what the promise is made
about.

The core is pinned at `1d6cdd7e0`, which is where those names start.

Closes #4

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:23:57 +02:00
.forgejo/workflows Run CI in an image that can check the repository out, and gate a tag elsewhere 2026-09-16 11:17:36 +02:00
src/postulo_dav Import one module from Postulo instead of nine 2026-09-21 17:23:57 +02:00
tests Import one module from Postulo instead of nine 2026-09-21 17:23:57 +02:00
.gitignore Contacts to CardDAV and interviews to CalDAV, both ways 2026-09-06 15:18:02 +02:00
LICENSE Contacts to CardDAV and interviews to CalDAV, both ways 2026-09-06 15:18:02 +02:00
pyproject.toml Import one module from Postulo instead of nine 2026-09-21 17:23:57 +02:00
README.md Stop writing to a server nobody asked to write to, and stop deleting what somebody just wrote 2026-09-16 10:47:36 +02:00
uv.lock Import one module from Postulo instead of nine 2026-09-21 17:23:57 +02:00

postulo-dav

Company contacts in a CardDAV address book and interviews in a CalDAV calendar, in both directions — on Nextcloud, Radicale, Baïkal, SOGo, Fastmail, iCloud and any server that speaks the standards.

What you want on the phone is the recruiters and the interviews. What you do not want is your family imported into a job tracker. So this plugin for Postulo works with two dedicated collections, an address book called Postulo and a calendar called Postulo, made on the server by the first sync if they are not there, and touches nothing else. Nothing else makes them: not Test, and not a connection set to read only.

Install

Into the environment of a running Postulo:

uv pip install git+https://source.tiagoagueda.com/postulo/postulo-dav.git

Restart Postulo. In a container, bake it into the image — see Plugins in the image on Installing Postulo. A server on your own network needs the operator's POSTULO_CONNECTIONS_ALLOW_PRIVATE=true.

Use

Under Settings → Connections → Add a connection, choose CardDAV and CalDAV:

Field What it is
Server address The server's root, e.g. https://cloud.example.org. Discovery (/.well-known/carddav and caldav) finds the rest. It must be https://: the password goes over the wire, and only a machine on your own network may be reached over plain http://.
Username, password An app password where the server offers one. Stored encrypted.
Address book, Calendar Both default to Postulo; made by the first sync if they are not there, never in read-only mode.
Read only Contacts and interviews still come in; nothing goes out — not even a new address book.
Run Every 15 minutes to once a day. The scheduler honours it; Sync now runs at once.

Press Test: it signs in, discovers the two homes and says which of the two collections are already there. It makes neither — the first sync does that. Then subscribe the phone to the Postulo address book and calendar the way you would to any other.

What travels

Contacts ↔ vCard 4.0. Name, company (ORG), role (TITLE), email, phone, LinkedIn (URL) and notes. A contact created on the phone in the Postulo address book becomes a contact here, under the company its ORG names or a (no company) holding company until you assign one.

Interviews ↔ VEVENT. Start, end, Kind: Role at Company as the summary, the place or the call link, the preparation notes and the application's link in the description, the people you are meeting as attendees — informational only: Postulo never sends invitations — and an alarm from the interview's reminder. Interviews from the last ninety days onwards are in the calendar. An event created on the phone stays on the phone: nothing links it to an application, and a job tracker must not invent one. Recurring events are ignored.

Both directions. What you edit here is pushed on the next run; what you edit on the phone is pulled. Moving an interview on the phone moves it here through Postulo's own service, so the timeline says so and the reminder follows. Cancelling it on the phone settles it here.

Conflicts. When both sides changed since the last run, the later change wins and the losing version is kept — as a line in the contact's notes, or as a timeline entry on the application.

Deletions. A record deleted on the phone is never deleted here: it is unlinked and flagged, the report says so, and an interview gets a timeline entry. A record deleted here takes its twin with it — but only the version of the twin the last sync saw. The delete carries If-Match, so a card or an event you edited on the phone in the meantime is not destroyed by a deletion on this side: it stays where it is and the run says so. A card left behind that way comes back as a contact on the next run; an event left behind is simply one more event that is not Postulo's.

Translations

French, European Portuguese and Brazilian Portuguese ship with the package, in src/postulo_dav/locale/. Postulo never translates a plugin's strings; a catalogue for every language Postulo offers is in place; fill one and run uv run postulo-messages compile. uv run postulo-messages extract refreshes them from the source, and uv run pytest -m release holds the twenty-four European Union catalogues complete before a release — a gate the tag job in CI runs, and every other run skips.

Develop

uv sync        # brings in the Postulo this plugin is released beside, for the tests
uv run pytest
uv run ruff check .

The tests run against a DAV server that lives in memory — discovery, two homes, collections, ETags, conditional writes and conditional deletes — and inside a real Postulo through its registry and scheduler. No request leaves the machine. One of them reads this package's own source and lists every name it takes from Postulo: postulo.plugins.api is the surface, and everything else is written down so the reach past it stays countable (postulo/postulo#229).

To work against a Postulo checkout beside this one rather than the pinned one:

uv pip install -e ../postulo
uv run --no-sync pytest

Licence

AGPL-3.0-or-later, like Postulo.