Aller au contenu

Writing a plugin

Cette page n'a pas encore été traduite ; elle est donc affichée en anglais.

Postulo is modular on purpose: anything that could reasonably vary sits behind an interface that a separately installed package can implement, and the built-in implementations are plugins that happen to ship in the box.

A plugin is an ordinary Python package. It says which kind it is by the entry-point group it registers in, and satisfies that kind's interface — a protocol, so there is no base class to inherit. Everything it may import from Postulo is one module, postulo.plugins.api; the full list is under Every name on the surface below.

Kind Entry-point group Interface What it does Section
source postulo.sources SourcePlugin Reads a job posting off a page Writing a capture source
importer postulo.importers ImporterPlugin Reads a career out of a file Importers
transport postulo.transports TransportPlugin Carries a message off this machine, by mail or by text Transports
notifier postulo.notifiers ConnectedPlugin, plus send() Tells somebody a reminder is due or a capture arrived Notifiers
outbox postulo.outboxes OutboxPlugin Sends mail as the person, from their own address Outboxes
store postulo.stores StorePlugin Keeps a copy of what was sent, and of uploaded files, somewhere else Stores
sync postulo.syncs SyncPlugin Keeps records here and records elsewhere the same Syncs
feature postulo.features FeaturePlugin A capability of Postulo itself, switched on and off; it is also how a plugin brings document themes Features, A theme, if you set documents
identifier none yet — External identifier schemes; internal for now Identifier registries
link-service postulo.link_services LinkServicePlugin Says which services a web link can be on, and what an address on each looks like Link services
messaging-service postulo.messaging_services MessagingServicePlugin Says which services a messaging handle (Signal, Matrix, XMPP…) can be on, and what a handle on each looks like Messaging services
backup-destination postulo.backups BackupDestinationPlugin Keeps the instance's backups somewhere besides its own disk, encrypted before they reach it Backup destinations
register-adapter postulo.register_adapters RegisterAdapterPlugin Says how a country's company register is asked about one number, and how its answer is read; Postulo makes the request, under the Register lookup feature Company registers

Capture sources were the first kind, and much of this page is about them. The kinds that talk to another service share the connection machinery under Plugins that connect to another service.

Writing a capture source

Postulo reads job postings through sources. Two are built in, and anyone can add more by installing a Python package — no fork, no patch to this project, and no waiting for it to be accepted.

This exists because the person who cares about a particular job board is almost never the person maintaining Postulo. If a board matters to you, you should be able to teach one instance about it in an afternoon.

What a source is

Anything with four names. There is no base class to inherit, on purpose: a plugin should not have to import Postulo internals, or track their changes, just to be recognised.

class MyBoardSource:
    name = "myboard"  # recorded against every capture this source produced
    version = "1.0"  # so a capture can be traced to the code that made it

    def can_handle(self, url: str) -> bool:
        """Whether this source wants to parse the URL."""
        return "myboard.example" in url

    def parse(self, url: str, html: str) -> JobPostingData | None:
        """Extract a posting, or return None if the page yielded nothing useful."""
        raise NotImplementedError

Sources are given a URL and the HTML fetched from it, and return data. That is all they do. A source does not touch the database, decide whether its own result is good enough, or create anything — the person capturing does that on the review screen.

That boundary is the whole design. A parser reading markup it has never seen gets things wrong, and when it does, the cost should be a few seconds of somebody's attention rather than a fabricated job title in their records.

The data you return

postulo.plugins.api.JobPostingData is a Pydantic model, and it is the only shape Postulo accepts. The built-in parsers, your plugin and the capture API all validate through it, which is what stops a source inventing a field or misspelling one.

Field Type Notes
title str The only required field
company_name str
location str Free text, as it should read on screen
remote_type str onsite, hybrid or remote
employment_type str full_time, part_time, contract, freelance, internship, apprenticeship
description str Plain text. Truncated past 40 000 characters rather than rejected
salary_min / salary_max Decimal \| None
salary_currency str Three letters
salary_period str year, month, day or hour
posted_at / closes_at date \| None
url str
source str Where it came from, usually the hostname

Unknown fields are rejected outright. If you need one Postulo does not have, open an issue — an extra column that only one plugin understands helps nobody.

Leave a field empty rather than guessing at it. A blank box on the review screen is an invitation to type; a confidently wrong value is something a person has to notice before they can correct it, and they will not always notice.

Registering it

Advertise an entry point in the group postulo.sources:

# pyproject.toml of your plugin package
[project.entry-points."postulo.sources"]
myboard = "my_package.source:MyBoardSource"

Install the package into the same environment as Postulo:

uv pip install my-postulo-myboard

That is the whole installation. Restart Postulo and the source appears on the capture page. Uninstalling the package removes it.

Translations: every plugin holds its own

Postulo speaks many languages, and a plugin must speak them itself. Its labels, help texts and messages are never added to Postulo's catalogues: a plugin author adds a language without waiting for a Postulo release, and a plugin translated into a language Postulo does not yet have still shows it. The rule is one directory:

my_package/
    __init__.py
    source.py
    locale/
        fr_FR/LC_MESSAGES/django.po
        pt_PT/LC_MESSAGES/django.po

Those directory names are gettext's, and they are the only place a language is written with an underscore: postulo-messages makes each from the language's tag, so fr-FR is kept in fr_FR and sr-Cyrl in sr_Cyrl. Everywhere else a language is a BCP 47 tag in its canonical form — pt-BR, never pt-br — which is what the tool is given, what Postulo hands a plugin, and what language_tag writes.

Wrap every string a person will read in gettext or gettext_lazy, exactly as Postulo does. Then, with Postulo in the repository's dev dependencies as the official plugins have it, run Postulo's own catalogue tool from the repository's root — no GNU gettext needed:

uv run postulo-messages extract          # a catalogue for every language Postulo offers
uv run postulo-messages extract --check  # fail if a catalogue is out of date
uv run postulo-messages check            # placeholders and plural forms agree
uv run postulo-messages compile          # write the .mo files Django loads
uv run postulo-messages guard            # no commit writes a translation (official plugins)

It reads pyproject.toml for the project's name and expects the package under src/, which is the layout above. extract creates a slot for every language Postulo has, and writes the catalogues in the layout Weblate writes, so a plugin translated in Weblate gets its pull requests through extract --check unchanged. django-admin makemessages still works for a plugin that would rather not depend on Postulo to develop.

Translate it wherever you like. A third party's plugin is its author's to translate. The official plugins are translated the way Postulo is: in Weblate at translate.tiagoagueda.com, never in a commit (postulo-messages guard in their CI refuses one), with the full set of a release's languages held by uv run pytest -m release in the plugin's own suite, because Postulo's gate cannot see a plugin's catalogues (#187).

A draft is flagged fuzzy, gettext's word for it and Weblate's needs editing, and postulo-messages compile compiles it -- so a machine draft is shown rather than English -- unless it would fail to format. Compiling with GNU msgfmt instead needs --use-fuzzy for the same result.

Ship the compiled .mo files in the package: run compile before building the wheel, rather than committing them, so a translation arriving from Weblate never leaves a stale one behind. When the registry loads a plugin it adds the package's locale/ to the directories Django reads catalogues from, so nothing else is needed; a plugin without a locale/ simply shows its English. The languages worth covering first are the ones Postulo itself ships (see TRANSLATING.md), and a catalogue that is only partly translated is better than none.

Postulo's own catalogue is read first, and that decides a tie. Django merges the locale directories in reverse order with each merge overriding the last, so the first wins, and a plugin's is always appended. If your plugin translates a string Postulo also uses — Name, Send, Draft — the reader sees Postulo's rendering of it, not yours. A plugin can add a word to the interface; it cannot change one.

So a word of yours that is also a word of Postulo's needs a context. If the same English string means something else in your plugin — State is a status in Postulo and a part of an address in the address rules — write it as pgettext_lazy("part of a postal address", "State"). It is then a message of its own, and your catalogue's translation is the one drawn. Write the context as a literal at each call: the catalogue tool reads the source and takes a context only from a literal. The address rules have a test that fails if any of their messages is also one of Postulo's (#651).

A plugin Postulo ships follows the same rule since #127, which it did not before: its catalogues sit beside its package and every check Postulo runs over its own catalogues runs over the plugin's too. scripts/messages.py finds them from the filesystem — a directory under src/postulo with a locale/ in it is a set of catalogues — so moving a built-in's strings out of core is a matter of creating the directory and re-running extract, with nothing to add to a list. The twenty-four European Union languages have to stay complete in every set, which is the guarantee that would otherwise have been traded for a tidier layout: for a plugin Postulo ships, Postulo is the author, and a string that leaves the completeness test's sight is not translated by somebody else — it is quietly untranslated.

How sources are chosen

Third-party sources are tried first, in the order the entry points resolve, then the built-in ones. A source written for a specific site knows more about it than a general parser does, so it gets first refusal.

For each source in turn, Postulo calls can_handle(url) and then parse(url, html), and takes the first result that is not None.

A source that raises is logged and skipped — the next one along may well cope, and a broken plugin should not take capture down with it. A source that does not provide the four names is refused at load time and logged. Neither failure reaches the person capturing, who simply gets a result from something else.

The built-in sources

schema.org reads the JobPosting object most large boards already embed as JSON-LD for search engines. It is a published standard which the sites maintain themselves, so reading it breaks far less often than guessing at their markup: there are no CSS selectors to repair when a board redesigns. Try this before writing anything — your board may already be covered.

page-metadata is the fallback. It reads what the page declares for a link to show (<title>, OpenGraph and Twitter card properties), splits a title line only where the page vouches for a part, and reads a salary and a closing date after the words the page's own language uses for them. Whatever it cannot vouch for, it leaves empty.

Between schema.org and page-metadata, Postulo tries the places a person's own corrections showed a field to be on that site. They are that person's data and are handed only to Postulo's own sources. A third-party source, or a subclass of a built-in one, is never given them and needs nothing from them.

Testing yours

Give it HTML and check what comes back. Do not write tests that fetch a live site: they fail for reasons that have nothing to do with your code, and usually at the worst moment.

def test_it_reads_a_posting():
    data = MyBoardSource().parse("https://myboard.example/j/1", SAVED_PAGE_HTML)

    assert data.title == "Senior Backend Engineer"
    assert data.employment_type == "full_time"

Keep a saved copy of a real page as a fixture, and refresh it when the board changes.

Fetching, and what your source does not have to think about

Postulo has already fetched the page by the time your parse is called, and it did so under rules your source inherits for free:

  • only http and https;
  • every address the hostname resolves to must be publicly routable, and an IPv6 address is judged by any IPv4 address it carries too, revalidated on each redirect;
  • the site's robots.txt is honoured;
  • one page, ten seconds a wait and thirty in all, two megabytes, three redirects.

If you find yourself wanting to fetch a second page from inside parse — an API the board offers, say — think carefully. You would be making requests outside all of the above, on an instance whose owner did not ask for them. Prefer teaching the API to postulo.plugins.fetching over reaching for httpx yourself.

Plugins that connect to another service

Sources are stateless. A notifier, a store or a sync talks to another service on a person's behalf, and needs to know where it is and how to sign in. That is a connection, and it is Postulo's business, not the plugin's: the plugin says what it needs, Postulo draws the form under Settings → Connections, keeps the answers — secrets encrypted, never shown back — and hands them over when it calls the plugin.

A connected plugin provides four names and two methods, no base class:

from postulo.plugins.api import FieldSpec, TestResult, client


class MyNotifier:
    name = "mynotifier"  # stable identifier, recorded on every connection
    version = "1.0"
    kind = "notifier"  # or "store", or "sync"
    label = "My notifier"  # what people see

    def config_fields(self):
        return [
            FieldSpec("url", "Server address", type="url"),
            FieldSpec("token", "API token", type="password", secret=True),
            FieldSpec("quiet", "Quiet hours", type="boolean", required=False),
        ]

    def test(self, config):
        """One real request, one sentence back. Runs when a person presses Test."""
        with client() as http:
            response = http.get(
                f"{config['url']}/ping", headers={"Authorization": config["token"]}
            )
        if response.status_code != 200:
            return TestResult(False, f"the server answered {response.status_code}")
        return TestResult(True, "reachable")

Field types: text, url, email, password, integer, boolean, choice (give choices), textarea, and discovered (give modes; see Choosing from what is on the service below). A field marked secret is stored encrypted and is never rendered back; config as passed to your methods holds configuration and secrets together.

If what you ask for depends on the person, add a user=None keyword to config_fields or to test, and Postulo hands you the account the connection belongs to. Most plugins need nothing of the kind — a server address is the same whoever types it — and a plugin without the keyword is called without it. The built-in email notifier is the example: its one field is a choice among the person's own verified addresses, because a notifier sends to the person and not to anybody they name, and test checks the same thing before it sends. Test is bounded per account (POSTULO_CONNECTION_TEST_RATE), so a plugin's test need not bound itself.

Register it in the group for its kind — postulo.notifiers, postulo.stores, postulo.syncs:

[project.entry-points."postulo.notifiers"]
mynotifier = "my_package:MyNotifier"

Connected plugins hold their own translations too — the locale/ directory described above applies to every kind of plugin.

When the other side has ended the connection, raise ConnectionUnusable instead of an ordinary exception:

from postulo.plugins.api import ConnectionUnusable

raise ConnectionUnusable(_("The provider revoked this token. Connect it again."))

An ordinary failure is recorded and tried again next time, which is right for a server that is down. This is the other kind — a revoked token, a withdrawn push subscription, an account that no longer exists — where every later attempt fails identically. Postulo switches the connection off, shows your message to the person, and forgets the stored secrets unless you pass keep_secrets=True. Nothing is deleted: switching it back on is one press once they have fixed whatever it was.

Not speaking HTTP? A plugin that opens its own socket — a mailbox, a message queue — asks for the address first, and keeps the name for TLS:

from postulo.plugins.api import DestinationRefused, approve_host

try:
    address = approve_host(config["host"])          # resolved, held to the instance's policy
except DestinationRefused as error:
    return TestResult(False, str(error))            # the message is for the person
client = SomeClient(address, port=config["port"], server_hostname=config["host"])

approve_host resolves the name, refuses it unless every address it answers with is allowed here (POSTULO_CONNECTIONS_ALLOW_PRIVATE decides what "allowed" means), and hands back the one to dial — so the address you connect to is the address that was approved, and no second lookup can answer differently. Prove the certificate against the name, never the address. check_destination(url) answers the same question for a URL without connecting.

Use postulo.plugins.http.client() for every request. It carries Postulo's timeouts and user agent, and it enforces the instance's destination policy on every request, redirects included: private and local addresses are refused unless the operator set POSTULO_CONNECTIONS_ALLOW_PRIVATE=true, which is where self-hosted services usually live. A plugin that opens its own connection bypasses that policy, and a reviewer will say so.

Two more methods are optional, and Postulo looks for them by name:

def validate(self, config) -> dict[str, list[str]]:
    """Runs when the form is submitted, with configuration and secrets together.

    Return problems keyed by field name; an empty key is a problem with the form
    as a whole. An empty dict means the configuration is fine.
    """
    if "://" not in config["url"]:
        return {"url": ["That is not an address."]}
    return {}


def summary(self, config) -> str:
    """One line for the connections list. Mask every secret part."""
    return f"{config['url']} as {config['token'][:2]}…"

validate is where a plugin that can tell a typo from a token says so — at the form, rather than at three in the morning when a reminder falls due. summary exists because secrets are never shown back: it is how a person tells two connections to the same plugin apart. A secret may be a textarea when it is naturally several lines (Apprise takes a list of URLs with the credentials inside); it is stored, masked and kept-when-blank like any other secret.

Another optional method puts links on the connection's card (#889), for a service that has no API a person can use but does have a space of their own: the page where a declaration is made, its messages.

def links(self, config) -> list[tuple[str, str]]:
    """(label, address) for the card: at most six, every address an https page."""
    return [("My space", "https://example.org/me"), ("Messages", "https://example.org/messages")]

They are drawn as external links (rel="noopener noreferrer", in a new tab, with the external-link icon). An address that is not an https page, or has no label, is dropped and logged, never drawn, and a links that raises costs the card its links and not the page.

A further optional method is for the rare plugin whose setup happens in the browser:

def form_attributes(self) -> dict[str, str]:
    """Put on the connection form's card as data-<name> attributes."""
    return {"web-push-key": public_key, "web-push-allow": _("Allow notifications")}

The built-in Browser notifier uses it to hand Postulo's script the instance's public key and the sentences it may need to say, so that pressing Allow can fill in a field the person could never type. Names are lowercase words joined by hyphens, and anything else is dropped; every value is escaped, so this puts attributes on the card and nothing more. It is in the page, so nothing secret belongs in it. A plugin cannot ship JavaScript of its own — the Content-Security-Policy allows only Postulo's — so what the page does with the attributes is Postulo's script's to decide, and a plugin that needs new behaviour there needs a change to Postulo.

Choosing from what is on the service (#332). Some choices only the server can offer: which address books, calendars and task lists exist, and which of them the account may write. Declare a discovered field with the modes an option can be used in, and a discover method that says what is there:

from postulo.plugins.api import DiscoveredOption, FieldSpec

def config_fields(self):
    return [
        FieldSpec("url", _("Server"), type="url"),
        FieldSpec("password", _("Password"), type="password", secret=True),
        FieldSpec(
            "collections",
            _("Collections"),
            type="discovered",
            modes=(("read", _("Read")), ("write", _("Write")), ("both", _("Read and write"))),
        ),
    ]

def discover(self, config) -> dict[str, list[DiscoveredOption]]:
    """Called only when the person presses *Look on the server*. Keyed by field name."""
    return {
        "collections": [
            DiscoveredOption("/dav/cards/home/", _("Home"), group=_("Address books")),
            DiscoveredOption(
                "/dav/cal/team/", _("Team"), group=_("Calendars"),
                modes=("read",), note=_("Read only on the server"),
            ),
        ]
    }

A DiscoveredOption is a value (what you know the option by, an address for a calendar server), a label the person reads, an optional group (a heading the rows are gathered under), an optional note beside the label, and optional modes, the values of the field's modes this option allows. Leave modes empty to allow all of them. Nothing in it is secret: it is drawn on the page.

  • When it runs. A saved connection's form has a Look on the server button. It posts the form to Postulo, which calls discover with the configuration as last saved, password included, and draws the form again. Whatever the person had typed is still there and none of it is saved. It never runs when the page opens or on Save. A connection not yet saved says to save first. It spends the same per-account allowance as Test (POSTULO_CONNECTION_TEST_RATE), is called outside any database transaction, and what it raises is shown to the person as the reason, in your words. The options found last time stay as they were.
  • What is stored. What was found is kept in the connection's plain configuration under the key postulo:discovered, with when it was found. Postulo owns that key: read it if you like, but do not write it. The field itself is stored under its own name as a dict of option value to mode, holding the chosen options only. config["collections"] above might be {"/dav/cards/home/": "both"}, and an option on Not used is not in it.
  • How it is drawn. As a table with one row per option, grouped. Each row has a select with Not used first, then the modes that option allows. A mode the option does not allow is refused at the form, and validate sees the choice with the rest of the configuration. The field's required is ignored, because a new connection has nothing to choose from until it is saved.
  • An option that vanished, chosen once and no longer found, keeps its row, its last label and its mode, marked No longer on the server, until the person sets it to Not used. It is never dropped behind their back. What a run does with it meanwhile is your call.
  • An option the server does not list cannot be chosen here. If you need one, keep a text field beside this one.
  • Postulo draws the choice and does not enforce it. Your plugin is handed what was chosen, and holding your own requests to it, reading only what may be read and writing only what may be written, stays your job.
  • Connections only. A transport or a backup destination that declares a discovered field gets a sentence in its place on the server page, and nothing is stored for it.

Dependencies. A plugin declares its own — apprise, imapclient, whatever it speaks — in its pyproject.toml, and nothing else: Postulo is not on PyPI, so naming it there sends pip looking for something it cannot find. In the container image a plugin is installed under the core's lock as a constraint, so a plugin cannot change the version of a package Postulo itself pins; declare the loosest bound that works. The core's rule Look for a library before you write one binds a plugin too: before hand-writing a standard or a format, look for an open-source library that does it, and say in the pull request what you found (see CONTRIBUTING.md in the core repository).

What each kind then does is that kind's own interface. So far:

Notifiers

A notifier adds one method:

from postulo.plugins.api import Notification


class MyNotifier:
    ...

    def send(self, notification: Notification, config: dict, user) -> None:
        """Carry one message. Raise on failure; Postulo records it on the connection."""

notification has an event (EVENTS, in notifications/base.py, is every one of them: reminder_due, capture_received, went_quiet, posting_closing, status_changed, interview_scheduled, offer_recorded, record_shared and share_ended), a title, an optional body and an optional url. Switch on the key, not on a list you copied: an event added later is a new key. user is the person it is for — fall back to their address or name if your service needs one. Every notifier connection automatically carries a switch per event, so your plugin never has to ask which events to deliver: if send() is called, the person wanted it.

Four more fields say what the message is, rather than how it reads:

Field What it is for
key What this message is, stable across every send that carries it — reminder:41. Deliver once per key and a retry does not say it twice. Empty means the sender claims no identity: deduplicate on nothing, and never on the words, which are translated per recipient
language The language the words are already in, as a BCP 47 tag in its canonical form (pt-PT). Postulo sets it from the recipient's own choice, so a subject line, a footer or a push payload you render around them matches
occurred_at When the thing happened, which is not when the message was built: a queued send is minutes old, and older if it was retried
data The thing itself in machine terms — the reminder's id, the application's, the due time. A sender's own vocabulary, so read it with .get and work without it

A send() that returns is recorded on the connection as a delivery that worked. A notifier that only queues the message, for something else to deliver later, says so with a class attribute, delivers_later = True: nothing has reached anybody yet, so Postulo then leaves the connection as it was. The Webhook notifier works this way, and its delivery pass writes how each delivery went. The connection is not on the plugin surface, so a plugin of your own that delivers later has no published way to write that outcome: its connection keeps what Test last said.

NotifierPlugin is the protocol, if you want to assert against it. The built-in postulo.plugins.email is forty lines and a fair template; postulo-apprise is a complete one built outside the core, with validate, summary, a secret that is a list, and the destination policy applied to the servers its URLs name.

Outboxes

An outbox sends mail as the person, from their own address, over their own server. It is a kind of its own rather than a second transport, and the reason is worth knowing before you write one:

transport outbox
Belongs to the instance the person
Sends as the instance the person
Configured Server settings → Email Settings → Connections
Switched off never, while it is the way back in by the person, or by an administrator
Can carry a password reset yes never

That last row is the point. Getting back into an account reads transports, so nothing that is not a transport can become a way in — which is what makes "the person may switch this off" safe to offer at all.

class MyOutbox:
    ...
    kind = "outbox"

    def send(self, message, config) -> int:
        """Send one Django EmailMessage. Return how many went. Raise if it would not go."""

The sender is not yours to rewrite. Postulo fills in the address the connection declares when the message has none, and refuses a message that claims a different one — it does not quietly replace it, because a message whose From was replaced is a message that looks forged. A bounce belongs to the person, not to the instance, and it can only reach them if their address is the one on the message.

Raise rather than swallow. A rejection from somebody's own mail server is theirs to read.

send(message, config) is called by postulo.core.correspondence.send from the Record what you sent page, with the outbox's own address already set as the sender; do not change it. Raise on failure: the page then shows a generic message and files nothing.

Stores

A store keeps a copy of a document somewhere else — an archive such as Paperless, a share, a folder. Local media stays the source of truth: rendering, serving, export and the review of what was sent never depend on a store, and a job search does not stop because an archive server is down. A store adds one method:

from postulo.plugins.api import DocumentMetadata, ExternalRef


class MyStore:
    ...
    kind = "store"

    def put(self, document, file, metadata: DocumentMetadata, config, user) -> ExternalRef | None:
        """Keep a copy. Return where it went, or None to say "not for me". Raise on failure."""

file is open for reading; metadata is plain values — kind (a DocumentKind), kind_label, origin (render or upload), title, filename, content_type, created_at, checksum, size, company, role, application_url, sent_on, language (a BCP 47 tag such as pt-BR, or blank where nobody has said), tags — enough to file it sensibly without opening it. Return an ExternalRef(store, id, url); Postulo keeps it beside the document, shows the link, and carries it in the export. Return None to decline a kind you do not keep (an archive for paperwork may decline a video); the person sees not accepted. Raise on failure: the scheduler retries with a growing wait and the document shows the error.

Postulo calls put from the scheduler, never inside a request, except when a person presses Send to stores now. It is called for what was handed over — a file somebody uploaded, a CV or a letter recorded as sent with an application, a report — and never for a PDF exported on its own, which is kept in Postulo only (#236). Every store connection carries a switch per document kind, so your plugin never asks which kinds to keep: if put is called, the person wanted it. browse() and delete() are reserved for a later stage and not called yet. The built-in postulo.plugins.localstore is the same contract applied to private media, and cannot be switched off.

Syncs

A sync keeps records here and records elsewhere the same, in both directions — contacts in an address book, interviews in a calendar. It adds one method:

from postulo.plugins.api import Contact, SyncLink, SyncReport


class MySync:
    ...
    kind = "sync"

    def sync(self, connection, config) -> SyncReport:
        """Compare both sides, push and pull what you must, say what you did."""
        report = SyncReport()
        for contact in Contact.objects.for_user(connection.owner):
            link = SyncLink.for_record(connection, contact)
            ...
            SyncLink.bind(
                connection, contact, remote_href=href, uid=uid, etag=etag, local_hash=digest
            )
            report.pushed += 1
        return report

What ties a local record to its remote twin is a SyncLink row against the connection, kept beside the record and never on it. It holds:

  • collection: the address of the address book, calendar or task list the twin lives in, blank where your plugin has no such thing;
  • uid: the identifier the remote knows the twin by;
  • remote_href: where the twin was last found;
  • etag, the version tag it last gave, local_hash, a hash of what you last pushed, and last_synced_at;
  • slot: a short key you choose, naming which of a record's twins this is. Blank means the only one. An application can have a submit task and a hear-back task, and an interview an event and a task. Slots are free text: one plugin's slots mean nothing to another;
  • remote_gone and unpaired_at, below.

Key a twin on its collection and its UID, and keep the address as a cache. A UID is unique only within its collection (RFC 6352 §5.1, RFC 4791 §4.1), so the same UID in two address books is two items. A server may move an item to another address (RFC 4791 §5.3.2), so look a twin up by what it is, not by where it was. A twin found again in another collection the person chose moves with it: bind it again with the new collection.

The calls:

  • SyncLink.for_record(connection, record, slot=""): the link of one record in one slot, or None.
  • SyncLink.of_model(connection, Model): every link of a model on the connection, every slot, paired or not.
  • SyncLink.for_remote(connection, collection, uid): the link from the remote side, or None. Where the item is paired you get that link; otherwise the one most recently unpaired.
  • SyncLink.bind(connection, record, slot="", **fields): create the link or update it. Binding pairs: it clears unpaired_at, and it clears remote_gone unless you pass it. One remote item is paired with one record per connection: binding a second record to a collection and UID that another record holds raises AlreadyPaired, whose link is the pairing that holds it.
  • SyncLink.unbind(connection, record, slot=""): undo a pairing, leaving the record and the remote item alone. The row stays, with unpaired_at set and paired false, and it returns the link, or None where there was none.

An unpaired link means "leave both sides alone". Check link.paired before you sync through a link from for_record or for_remote. Skip an unpaired record and an unpaired item: do not push the record as a new item, and do not adopt the item as a new record. They stay apart until either is bound again. An unpaired row does not hold its item, so the person may pair that item with another record.

remote_gone is yours to set and to clear. When the other side deletes a twin, set remote_gone on the link rather than deleting the local record: a swipe on a phone must not erase an interview. bind clears it when you pair the record again.

Pairings are not in the personal export, because connections and their secrets are not. A connection added again on another instance pairs again by UID.

Pairing by hand (#327). What your plugin cannot pair by UID — this card is that company, this event is the second interview — the person pairs on the connection's Pairing page, under Settings → Connections. Postulo draws the page; your plugin answers two optional methods, and a plugin without the first has no page.

  • remote_items(config, search="", kind="", key=None) -> list[RemoteItem] lists the items the person may pair, narrowed by search (the words they typed) and kind (one of RECORD_KINDS, or empty for any). Given key, a (collection, uid) pair, answer with that one item, or with nothing if it is gone. It is asked live, only when the page needs an item — a search, Show unpaired, or the item a pairing is about to be made with — with the configuration as last saved, under the person's own allowance (POSTULO_CONNECTION_PAIRING_RATE), and with every wait your plugin makes through client() held to fifteen seconds all told. Answer from a search on the server rather than a full listing: at most a hundred items are drawn. Postulo keeps nothing of what you answer but the SyncLink a pairing writes.
  • fill_record(connection, config, item, kind, into=None) makes a record of kind from the item, or fills into — one of the person's own — and returns it. Write it through the surface's calls (create_contact, update_contact, update_company, get_or_create_department, create_reminder, schedule_interview…), naming your plugin as the actor (via= or actor=), never by saving a model. What you return must be a record of that kind and the connection owner's own, and into itself when you were given one; anything else is refused and nothing is paired. A person is never made: you is paired, not filled. Raise with a sentence when you cannot; it is shown in your words.

Then Postulo pairs the two with SyncLink.bind, writing the item's collection, uid, slot and href from your answer, never from the posted form — a forged address would be where your next run sends the password — with no etag and no local_hash, so your next run makes the first comparison. Fill once, without pairing is the same call without the bind: a one-off import. Unpair is SyncLink.unbind's "leave both sides alone". For a record whose twin is gone, Let … make a new one deletes the link, so your next run finds a record with no twin and makes it one, as it does a record made since the last run.

Each record's own page — a company's (and its departments'), an application's, the edit pages of contacts, interviews and reminders, and Your details — says "Paired through …, last synced …" or "Removed on the other side", read from SyncLink alone: a record's page never waits on your server.

What you may walk, and what you may write. A sync is the one kind of plugin that is not handed its work — it goes and finds it — so the records are on the surface, and so are the calls that write to them. Scope everything you read with for_user(), as you would your own rows.

A company, a contact or a listing may be a group's, or shared (#896). Read them with visible_to(user), change their details only within visible_to(user, write=True), delete or share them only within manageable_by(user), and match a name within owned_by(principal); for_user() answers as visible_to() for one release. Their notes and a listing's state, discard_reason, noted_at and decided_at are each person's: read them through with_notes(user) / with_status(user) or note_for(user) / status_for(user), and write them with write_note(user, text), shortlist(user), discard(user, reason) and restore(user) — read for nobody, they raise NotReadFor. Making a record with notes= or state= still works, as its owner's own.

Name What it is
Application, Contact, Interview, Suggestion The records a sync keeps two sides of
EventKind, InterviewOutcome The vocabularies those carry
get_or_create_company An employer by name, made once however many times it is seen
record_event One line on an application's timeline, with your plugin as the actor
record_listing_event One entry in a listing's history — a mail, a message, a call — for a listing or an application's listing, with your plugin as the actor
reschedule_interview, settle_interview Moving a meeting, and saying how it went
schedule_interview, InterviewKind, InterviewRefused Making an interview (#330), the one way one comes to be: schedule_interview(application, kind=..., starts_at=..., ends_at=None, location="", notes="", contacts=(), remind=True, actor="", uid="") writes the interview scheduled line on the timeline with your plugin as the actor, makes the reminder a day ahead through create_reminder, and records one already over as held. kind is one of InterviewKind's values; an end left out lasts an hour. Before writing anything it refuses — exactly as the form and the API are refused — a kind that is not an InterviewKind, an end that is not after the start, and anybody in contacts who is not the owner's own contact at the application's company, with an InterviewRefused: a ValueError whose field is kind, ends_at or contacts and whose message says why in the person's language
Appointment, AppointmentHow, add_appointment A meeting with an adviser at a public employment service, typed by hand (#888). add_appointment(company, starts_at=..., how=..., place=..., contact=...) puts one in the diary with a reminder a day ahead; the owner is the company's, and a company that is not a public employment service, or an adviser not recorded there, is a ValueError
Reminder, create_reminder, change_reminder, postpone_reminder, complete_reminder, reopen_reminder A reminder, and one call each to make, change, move, tick off and untick one (#331): what a sync of a task list needs. Read reminders with Reminder.objects.for_user(user); write them only through these, never by saving the model. Each takes an actor — your plugin's name — recorded on the reminder and drawn beside it as via …, so a reminder ticked off by a phone says so. create_reminder(owner, summary=..., due=..., application=None, actor="") and change_reminder(reminder, summary=..., application=..., actor="") (leave either out to keep it; application=None is about none) refuse another person's application with a ValueError and cut the summary to 250 characters. create_reminder and postpone_reminder(reminder, due, actor="") read a date as nine in the morning in the owner's time zone, even outside a request; postpone_reminder lets a moved reminder be announced again, and leaves a done one alone. complete_reminder(reminder, at=None, actor="") records when it was done, now by default and never in the future (a ValueError), for a tick you learn of late. reopen_reminder(reminder, actor="") unticks it and does not announce it again; move it if it should be. change_status is not on the surface: propose a status with suggest
add_reminder, retire_reminder A dated entry on the calendar that belongs to no application, such as a monthly declaration or an expected payment (#887). add_reminder(owner, summary=..., due_at=..., uid=..., source="your-plugin", note="", actor="") is idempotent by uid: a new one is made by create_reminder, and a second call moves the date and rewords the entry in place, brings back one that was retired, and never touches done_at, so what a person ticked stays ticked. source is drawn beside the entry; one a person made cannot be overwritten (a ValueError). retire_reminder(owner, uid) ends an entry you placed: kept, neither drawn nor announced. Removing the plugin retires everything it placed. These entries are announced like any reminder, with your plugin's name in the message
primary_phone_number, primary_web_link, LinkKind The number and the profile a contact leads with — rows of their own, not columns
save_phone_number, save_web_link Writing one back, with the primary flag and the uniqueness rules left to Postulo. save_web_link raises ValidationError for anything that is not a complete http(s) address: catch it and note the card rather than fail the run
phone_number_is_taken Called as phone_number_is_taken(number, asked_by=account): whether another row holds the number, charged to the account's allowance of answers: once it is spent every number answers yes. Without asked_by it still answers, with a DeprecationWarning, and is to be removed
event_lines, calendar_status RFC 5545 for one interview, the way Postulo's own feed writes it. event_lines(interview, alarm=True) carries the reminder as a VALARM
read_calendar, CalendarEntry, CalendarRefused The other direction: read_calendar(data) reads the bytes of an .ics file into CalendarEntry values: uid, start and end, or a day, summary, location, description (an alarm's text is never in it), status, completed and a refusal for what cannot be placed, such as a repeating event. A time with no usable zone is never read as UTC: it is local, with the zone the file named. The file is held to bounds before it is parsed, and CalendarRefused says why in words. The library itself is not on the surface; a plugin that wants icalendar declares its own dependency
reminder_lines A reminder as a VTODO, the way the reminders feed writes it: reminder_lines(reminder, url=...). Its uid is stable across edits and exports
calendar_items, CalendarItem, CalendarItems What else is on the person's calendar, read only (#330). calendar_items(user, start, end, kinds=None) answers the dated things the Calendar page shows on the days in [start, end) — at most a year — soonest first, as CalendarItem plain values: kind (interview, reminder, appointment, deadline, closing or answer; narrow with kinds=, and a kind nobody knows is a ValueError), title and detail (the page's two lines), starts_at and ends_at for a moment or only day for a whole day (all_day), over (drawn struck through: done, cancelled, already sent, already decided, already answered) and state (no_show), said (what the page says before the title for a reader who cannot see the colour, in the language in force: Done reminder:, Cancelled interview:; empty where the title says it), path (the page on this instance; join it with POSTULO_PUBLIC_URL from Django's settings, as for any record's get_absolute_url()) and uid, its name in a calendar. It reads in the person's own time zone and language whoever calls it, so a run from the scheduler sees their days. No Offer or JobPosting is on the surface: the one place that decides what a deadline is decides it for you too. With POSTULO_PUBLIC_URL unset there are no whole days — their name would be a UUID over a bare path, the same on every instance — and CalendarItems.withheld says so in words you can put in your report
DayEntry, day_lines A whole day as the feed writes it: each whole-day CalendarItem carries its entry, a DayEntry (summary, day, url, description, over, uid), and day_lines(entry) is its VEVENT, the same lines a subscribed calendar reads, under the same UID
RemoteItem One item on the other side, as remote_items offers it for pairing by hand (#327): collection (the address of its address book, calendar or task list) and collection_label, uid, href (its address), label, detail (a second line), kinds (the RECORD_KINDS it may be paired with) and slot (the link's slot, empty for a record's only twin). key is (collection, uid). Every part is drawn as escaped text, never as a link; an item with no uid, no label or no kind Postulo knows is left out
RECORD_KINDS The kinds of record an item may be paired with, the core's words: contact, company, department, you (the person's Your details), interview, reminder, application

Never write by saving a model. The timeline has to read the same whoever wrote to it, and an automatism has to be undoable by hand — which is what the actor on each of those calls is for. Every sync connection carries an interval — fifteen minutes to a day — and the scheduler runs it when that comes round; Sync now on the connection runs it at once. A sync runs as its owner, in their language and time zone, whether the scheduler or Sync now started it, so text it writes (notes, timeline entries, reminders) is in their language and names their hour. Return a SyncReport with counts and notes for anything a person should know; raise only when the run cannot happen at all. The report's summary and any error are shown on the connection. postulo-dav is the reference: CardDAV and CalDAV, both directions.

Suggesting, instead of writing

A plugin that reads something outside Postulo — a mailbox, a calendar, a board — is guessing, and a wrong guess in the record is worse than no guess at all. So nothing a plugin infers is written straight into an application. It becomes a suggestion:

from postulo.plugins.api import EventKind, suggest

suggest(
    connection.owner,
    source="imap",  # your plugin's name
    external_id=message_id,  # what you call it; makes this idempotent
    application=found,  # or None when you cannot tell
    kind=EventKind.REJECTION,  # what it thinks happened
    summary="We are moving forward with other candidates",
    body=excerpt,
    occurred_at=when,
    suggested_status="rejected",  # optional: a Status to move it to
    proposed_dates=["12/09/2026 14:00"],  # optional: dates a message offered, as written
    context={"From": sender},  # anything the person should see
)

It lands under Applications → Suggestions, and the person accepts or declines it. Accepting writes it through record_event or change_status with your plugin's name as the actor, so the timeline shows what an automatism did and the person can undo it by hand. Given an external_id, suggest is idempotent for that source and person — a second call finds the first suggestion and changes nothing, whether it is waiting, accepted or declined. That is what lets a mailbox be read every five minutes without asking the same question twice.

Filing into a listing's history

A listing has a history (#270): what arrived about a job before anybody applied — the board's "closes on Friday", a counsellor's message, a forwarded description. One call writes to it:

from postulo.plugins.api import EventKind, record_listing_event

entry, created = record_listing_event(
    application,  # or a listing: the entry goes on the listing either way
    kind=EventKind.EMAIL_RECEIVED,  # or "note", "call", "message", "document", "other"
    summary=mail.subject,
    body=mail.text,
    occurred_at=mail.date,
    actor="imap",  # your plugin's name, shown beside the entry
    external_id=mail.message_id,  # makes this idempotent for that listing
)
  • It is handed a record you already hold — a listing, or an application whose listing it is — and the owner is that record's. You never say whose history you write.
  • Whatever it names has to be the same person's. contact= (who it came from) and artefact= (one of their uploaded files; a document entry needs one) are refused with NotBound, a ValueError, if they belong to anybody else. It stores nothing of its own: a file is pointed at where it already is.
  • Given an external_id, it is idempotent for that listing: a second call finds the first entry, changes nothing, and answers created as False — suggest's rule, so a mailbox read every five minutes files a message once.
  • The text is a stranger's, and is held to what Postulo keeps: the summary folded to one line of at most 250 characters, the body cut at 40,000 with a marker saying so, and no NUL in either.
  • capture is the one kind it will not write for you: that entry is what binding a second capture writes, from the review screen, pointing at the capture.

Record what you know; suggest what you guess. A history is a record of what arrived, and the same rule holds as for an application: a message in a thread already bound, or one the person chose, belongs here; a message you matched on a company's name is a guess, and a guess belongs in suggest.

What you may import from Postulo

One module, and it is a promise:

from postulo.plugins.api import FieldSpec, TestResult, declares, shipped

And one way to prove you kept it. postulo.plugins.testing is the check Postulo runs over the plugins it ships, published so that yours can run it over itself (#229). It reads your source rather than importing it, so a lazy from postulo.core import site inside a method fails it too:

from pathlib import Path

from postulo.plugins.testing import assert_imports_only_the_surface


def test_the_plugin_imports_only_the_surface():
    assert_imports_only_the_surface(
        Path(__file__).parent.parent / "src" / "postulo_yours", package="postulo_yours"
    )

Pass allowed={"postulo.something": "the reason"} for a dependency you have decided on deliberately — and it fails on an entry you left behind after removing the import, because a stale one hides the next real dependency.

And a way to see what your call wrote. postulo.plugins.testing.listing_history(record) reads a listing's history back as plain values, newest first — each entry's kind, summary, body, occurred_at, actor, external_id, contact (a name, or empty) and points_at ("capture", "document" or empty) — so your tests can assert on what record_listing_event did without depending on the table underneath it, which is Postulo's to change.

And pairing by hand, as the page does it (#327). postulo.plugins.testing has pair_by_hand(connection, record, collection=..., uid=...), unpair_by_hand(connection, record, slot="") and create_by_hand(connection, collection=..., uid=..., kind=..., into=None, pair=True). Each goes through what the Pairing page goes through — the item asked of your remote_items again by its key, the link written from that answer, what your fill_record returns held to the same check — without a browser, so your tests can run your sync next and see what it makes of each. A refusal raises postulo.plugins.pairing.Refusal, and an item another record holds AlreadyPaired.

Everything else in postulo is this month's internals. postulo.plugins.base, postulo.core, postulo.accounts — whatever they look like today, they may move in a patch release without a word. postulo.plugins.api is the only thing that will not.

Total independence is not the goal and cannot be. Four things are reasons to depend on Postulo, and skipping any of them breaks something the project promises rather than merely being untidy:

You need Because
OwnedModel, OwnedQuerySet a plugin holding one person's data scopes it with for_user(), or one person sees another's
safe_next a redirect that skips it is a way to bounce somebody off the instance
client an outbound request that skips it is a way to make the server dial where it should not
public_only_client for what is public by definition — a posting, a portfolio, a logo, a push service. It refuses a private address whatever the operator allowed for connections, because that decision was never an answer for these
approve_host, check_destination, DestinationRefused the same rule for a plugin that speaks something other than HTTP: a socket opened on an unapproved address is the same hole as an HTTP request to one
read_body, deadline_in, until, BodyRefused reading what a server you do not control sends back: client bounds each wait but not their sum, and reads a body whole. Open until(deadline_in(seconds)) around the request, stream it, and read it with read_body(response, limit=..., deadline=...), which stops at the limit and raises a BodyRefused (too large, too slow, in a coding it will not unpack) instead of holding a worker for whatever the server sends
access_token ask for a token at the moment of use; a plugin that keeps one has stopped refreshing it
ACCESS_TOKEN where a consent plugin whose method is handed settings rather than a connection finds the token Postulo renewed for it just before a send or a test (#151)

The rest of the surface is what you declare and what you hand back: Manifest, declares, shipped, manifest_of, label_of, description_of; FieldSpec, DiscoveredOption, TestResult, Consent; JobPostingData, SyncReport, TextMessage; MAX_IMPORT_BYTES, ImportRefused, refuse_unreadable; MAIL, TEXT, MEDIUMS, medium_of; and every protocol.

needs_consent may take the connection's settings. A plugin that always authenticates by consent declares needs_consent(self) and returns a Consent. One that sometimes does — Your own email signs in with a password for a server of one's own and by consent for Google or Microsoft 365 — declares needs_consent(self, config) and returns None when the settings say a password. Postulo passes the settings to whichever form the method takes (#151).

Asking for anything else raises, and says where to look:

AttributeError: 'Connection' is not part of the plugin surface. See https://source.tiagoagueda.com/postulo/postulo/wiki/Writing-a-plugin

Every name on the surface

Everything postulo.plugins.api offers. Nothing outside this list is a contract.

What each kind of plugin satisfies

Name What it is
ConnectedPlugin What a plugin that connects to another service must provide: its name, version, kind and label, the fields it needs (config_fields()), and a test()
SourcePlugin What a capture source must provide
ImporterPlugin What something that reads a career out of a file must provide
Record The career an importer's read() fills in, in Postulo's terms rather than the file's; see Importers
TransportPlugin What something that carries a message off this machine must provide
FeaturePlugin A capability of the application itself, switched on and off like any other plugin
OutboxPlugin A connected plugin that sends mail as the person, from their own address
StorePlugin A connected plugin that keeps a copy of a document somewhere
SyncPlugin A connected plugin that keeps records here and records elsewhere the same. Its optional remote_items and fill_record give its connections a Pairing page (#327)
BackupDestinationPlugin Somewhere the instance's backups are kept besides its own disk: config_fields(), test(), send(), list() and delete(), with validate() optional

Saying who the plugin is, and what it needs

Name What it is
Manifest Who a plugin is: everything it says about itself, in one place
declares Class decorator: attach a manifest, and satisfy the protocol from it
shipped A manifest for a plugin that ships inside Postulo
manifest_of Everything a plugin says about itself, wherever it happens to say it
label_of A plugin's name in words, falling back to the identifier it registered under
description_of What a plugin says it does, or nothing
FieldSpec One thing a plugin needs from a person: an address, a token, a folder name
DiscoveredOption One thing a plugin's discover found on the service, offered on the connection's form with a mode to choose (#332)
Consent What a plugin needs a person to agree to, and where

What a plugin is handed, and hands back

Name What it is
TestResult What test() came back with: whether it worked, and a sentence for the person
SyncReport What one run of a sync did, in numbers and in sentences
JobPostingData A posting as some source understood it
TextMessage One text message: where it goes and what it says
DocumentMetadata What an archive needs to file a document without opening it
ExternalRef Where a copy went: which store, its id there, and a link if the store has one
BackupArchive One backup as it leaves, already encrypted with age: its name (ending .age), its size, and open() and chunks() to read it. Never a path
BackupManifest What that archive says about itself, as plain values, for naming and logging: the archive's name here, when it was made, the Postulo version, the database engine, the instance's name and the record counts. Never written beside the archive
BackupRef Where a destination put an archive: an id of its choosing, handed back to it unchanged, a label for the list, and created_at where it knows

Reading a file somebody chose

Name What it is
MAX_IMPORT_BYTES The largest file any importer is handed: 5 MiB (5242880)
ImportRefused The file will not be read, and the message says why, to the person who chose it
refuse_unreadable What Postulo refuses before any importer sees a byte

What a transport carries

Name What it is
MAIL, TEXT The two mediums, "mail" and "text"
MEDIUMS Both, as a tuple
medium_of What a transport carries, defaulting to mail for one that predates the question

The reasons to depend on Postulo

Name What it is
OwnedModel, OwnedQuerySet The base for anything that belongs to one person, and the queryset whose for_user() scopes it
safe_next The request's next when it stays on this host and scheme, fallback otherwise
client An httpx.Client with Postulo's defaults and the destination policy attached
access_token For a connection that signs in by consent: a token good for the next minute at least, refreshed if it is not
ACCESS_TOKEN The settings key ("oauth_access_token") under which a consent plugin handed settings finds the token Postulo renewed for it

Setting documents

Name What it is
Theme One way of setting a document, and the kinds it is willing to set
ThemeKind The shapes Postulo authors — CV, portfolio, letter, report, list, calendar — which are the shapes a theme sets
load_theme_asset load_theme_asset(package, name) reads one file of a theme's package and prepares it as {% theme_asset %} would — the markup of a sanitised SVG, or the data: address of an image or a font — and raises ThemeAssetRefused where the tag would write nothing. For a plugin's own tests
ThemeAssetRefused Why a theme's file is not written: a path out of the package, a file that is none of the kinds the tag takes, a font nobody declared, or a ceiling
MAX_THEME_ASSET_BYTES, MAX_THEME_DOCUMENT_BYTES What one file a theme inlines may weigh (1 MiB), and all of one document's together (3 MiB)
DocumentFormat A format a document can be downloaded in beside the PDF: key, label, extension, media type, and a function from an Outline to bytes
register_document_format Offers one; a key already taken is refused
Transfer, register_transfer A way to import into, or export from, one of the pages that have Import and Export beside their heading (subject is calendar, details or career; direction is import or export). It is a link: Transfer(key, subject, direction, label, url), where url is a function of the request returning the address to go to, and order (200 by default; Postulo's own are 100) sets where it is listed. One transfer in a direction is a link straight to it; several open a popover listing them. A key already taken is refused (#862)
Outline A document as its words — headings, paragraphs and lists, the language and direction, the title, author, subject and keywords

Carrying a notification

Name What it is
Notification The message a notifier's send() is handed; see Notifiers for its eight fields
NotifierPlugin What a notifier must provide, as a protocol
EVENTS Every event a person may switch on, keyed as they are stored on a connection

Reading a page

Name What it is
COUNTRY_CODES The ISO 3166-1 alpha-2 codes Postulo knows a country by, the table its telephone numbers are read against
CURRENCY_CODES The ISO 4217 codes a salary is read in: the list the spreadsheet importer uses, so there is one list rather than two
place_of Whether some text is a place Postulo's offline table of cities knows: a dict (name, country, lat, lon) or None, which is also the answer where the table has not been downloaded

Naming a language

Name What it is
language_tag A language code as Postulo writes one, the canonical form of a BCP 47 tag: pt-BR from pt-br, PT_br or pt_BR. What is not a tag comes back as it was given
is_language_tag Whether some text is shaped like a language tag, in any case. Ask it of anything read from outside before keeping it
language_match The closest of some codes to the one wanted, or nothing: itself whatever the case, then its shorter form (pt for pt-BR), then another of its family (pt-PT). Postulo's own languages where no list is given
language_matches Every one of them that matches, the closest first
language_override A with block drawn in a language, as Django's translation.override but through the catalogue Postulo has for it: use it, not translation.override, to write something in the language of a file

Keeping two sides the same

Name What it is
SyncLink One local record's twin on the other side, one per slot: its collection, the identifier, the remote address, the version tag, a hash of what was last pushed. for_record, of_model, for_remote, bind and unbind, as described under Syncs
AlreadyPaired What SyncLink.bind raises for a remote item that another record already holds on the connection. Its link is the pairing that holds the item
RemoteItem One item on the other side a sync plugin's optional remote_items offers for pairing by hand: its collection and the collection's label, uid, address, label, a second line, the RECORD_KINDS it may be paired with, and its slot (#327)
RECORD_KINDS The kinds of record an item may be paired with: contact, company, department, you, interview, reminder, application
Application, Contact, Interview, Suggestion The records a sync works on. Scope every read with for_user()
EventKind, InterviewOutcome What a timeline line may be, and how a meeting ended
suggest File a guess for a person to answer, instead of writing it into the record
get_or_create_company An employer by name, made once however many times it is seen
record_event One line on an application's timeline, with your plugin named as the actor
record_listing_event One entry in a listing's history, for a listing or an application's listing, with your plugin named as the actor. The owner is the record's
reschedule_interview, settle_interview Moving a meeting, and saying how it went
schedule_interview, InterviewKind, InterviewRefused Making an interview (#330), with the timeline line, the reminder a day ahead and the refusals the form and the API have: a kind that is not an InterviewKind, an end not after the start, or anybody met who is not the owner's own contact at the application's company is an InterviewRefused (a ValueError, its field naming the argument), and nothing is written
Appointment, AppointmentHow, add_appointment A meeting with an adviser at a public employment service, typed by hand (#888). add_appointment(company, starts_at=..., how=..., place=..., contact=...) puts one in the diary with a reminder a day ahead; the owner is the company's, and a company that is not a public employment service, or an adviser not recorded there, is a ValueError
Reminder, create_reminder, change_reminder, postpone_reminder, complete_reminder, reopen_reminder A reminder, read with Reminder.objects.for_user(), and the one call for each thing a task can have done to it: made, changed, moved, ticked off, unticked (#331). Each takes an actor, drawn beside the reminder. A date means nine in the morning in the owner's zone; complete_reminder takes the moment it was done, never in the future; unticking does not announce it again, moving it does
add_reminder, retire_reminder A dated entry on the calendar that belongs to no application, such as a monthly declaration or an expected payment (#887). add_reminder(owner, summary=..., due_at=..., uid=..., source="your-plugin", note="", actor="") is idempotent by uid: a new one is made by create_reminder, and a second call moves the date and rewords the entry in place, brings back one that was retired, and never touches done_at, so what a person ticked stays ticked. source is drawn beside the entry; one a person made cannot be overwritten (a ValueError). retire_reminder(owner, uid) ends an entry you placed: kept, neither drawn nor announced. Removing the plugin retires everything it placed. These entries are announced like any reminder, with your plugin's name in the message
primary_phone_number, primary_web_link, LinkKind The number and the profile a contact leads with, which are rows rather than columns
save_phone_number, save_web_link Writing one back, leaving the primary flag and the uniqueness rules to Postulo. save_web_link raises ValidationError for anything that is not a complete http(s) address
phone_number_is_taken Called as phone_number_is_taken(number, asked_by=account): whether another row holds the number, charged to the account's allowance of answers: once it is spent every number answers yes. Without asked_by it still answers, with a DeprecationWarning, and is to be removed
event_lines, calendar_status RFC 5545 for one interview, written the way Postulo's own feed writes it
read_calendar, CalendarEntry, CalendarRefused An .ics file read into plain values, bounded before it is parsed; a time with no usable zone is never read as UTC
reminder_lines A reminder written as a VTODO
calendar_items, CalendarItem, CalendarItems The Calendar page's dated things for a person and a range of days, as plain values in the person's own time zone: interviews, reminders, appointments, deadlines, closing dates and answers due, each with its kind, its two lines, its moment or its day, whether it is over and what the page says it is (said, #342), its path and its calendar uid (#330). No whole days while POSTULO_PUBLIC_URL is unset; withheld says why
DayEntry, day_lines A whole day's entry, and its VEVENT written as the feed writes it

The service a web link is on

Name What it is
LinkService One service a web link can be on: a key, a name, the kind of link, its hosts, the pattern an address's path has there, an icon, an optional brand mark, an example and, for a social profile, the prefix a row shows in front of the name
LinkServicePlugin What a registry of them must provide: name, kind = "link-service" and services
link_service_of The service with a given key, optionally of a given kind; None for Other and for a key nothing installed knows
MessagingService One service a messaging handle can be on: a key, a name, the pattern a handle has there (matched whole, once normalised), a normaliser, an icon, an example and the provider who offers it
MessagingServicePlugin What a registry of them must provide: name, kind = "messaging-service" and services
messaging_service_of The service with a given key; None for Other and for a key nothing installed knows
RegisterAdapter How one company register is asked (#1021): a key, a label, the scheme (register or lei) and country it answers for, the hosts it may be asked on, addresses(number), read(document, number), the source, licence and licence_url named beside what came back, an optional page(number) and extra headers
RegisterRecord What an adapter reads out of an answer: the number, the name, a RegisterAddress, the state (active, administration or dissolved) with the register's own words and date, the activity code and its NACE division, and from GLEIF registered_as, registered_at and jurisdiction. Every string is cleaned again by Postulo
RegisterAddress The registered office: street, postcode, municipality, region and a two-letter country
RegisterAdapterPlugin What a registry of them must provide: name, kind = "register-adapter" and adapters

A contact as a vCard (#660)

Name What it is
VCard One card as plain values: name, kind (individual or org), company, department, role, email, phones, addresses, links, impps, notes, a uid and a revised time. No model
VCardPhone, VCardAddress, VCardLink The rows on a card: a number with its kind and whether it is the primary, an address as its parts with a two-letter country, a web address
vcard_from_values A VCard as vCard 4.0 text: folded at 75 octets, every control character removed, N never invented from the name
vcards_from_text The cards of a 3.0 or 4.0 file, bounded in size, cards and line length, with what each left out named in dropped; nothing it names is fetched
VCardRefused What vcards_from_text raises for a file it will not read, with the sentence to show

Companies, departments and the person's card (#328)

Every write takes actor=, the account the work is done for, and via= to name your plugin in the record's trail. A record has to be one actor may change — its own, its group's, or one shared with it read/write; a read-only share is refused — and a department or a row is owned as the record you hand in is (a contact is actor's own, as the ones added at a shared company are). A refusal is a WriteRefused (a ValueError) with the reason in words, raised before anything is written. Never write these by saving a model.

Name What it is
Company, Department, CompanyKind The records and the kinds of company. Scope every read with for_user()
PhoneNumberKind, PostalAddressKind The kinds a number and an address may be
PhoneRow, AddressRow, LinkRow A holder's rows as plain values: the kind, the name given when none fits, the value and whether it is the primary
numbers_for, addresses_for, links_for numbers_for(holder, person) and the other two read a holder's rows as that person is shown them: with Several telephone numbers off, the primary alone, and likewise for a kind of link whose switch is off
PersonCard, person_card person_card(user): the person's own card, read only — given and family name, the sign-in address (User.email, not allauth's other rows), headline, location, the kind of picture (upload, gravatar or blank) and their numbers, addresses and links. Nothing on the surface writes a User or a Profile, and the rows calls refuse a profile as a holder. Identifiers and messaging handles are not on it
update_company update_company(company, actor=..., name=..., website=..., careers_url=..., location=..., notes=..., kind=..., parent=...): only the fields named change; notes is actor's own. A name another of the owner's companies has is refused, never merged
get_or_create_department get_or_create_department(company, name, actor=...): the department by name, spelling and case aside, or a new one
create_contact, update_contact create_contact(company, actor=..., name=..., role=..., email=..., department=..., notes=...) and update_contact(contact, actor=..., ...). A department of another company is refused
save_phone_numbers, save_postal_addresses, save_web_links save_phone_numbers(holder, rows, actor=...) makes a company's or a contact's rows the ones you pass: a row that stays keeps its record, one not listed goes, and exactly one is primary (one per kind for links). A row the rules refuse — a number that cannot exist, a duplicate, a postcode its country never uses, an address that is not http(s) — comes back in SavedRows.refused with its reason instead of raising, and the rest are saved. With a switch off only the primary is written (held_back counts the rest) and the rows the person is not shown are left as they were
SavedRows, Refused What those return: rows as the person is shown them afterwards, refused (each a Refused with the row you passed and the reason) and held_back
WriteRefused What every write here raises for a record that is not yours to change, a name already taken, a department of another company, or structure switched off

While employer structure is off for actor, reads work as ever, but a new department, a contact put in one and a parent are refused, so a sync cannot build what the person is not shown. actor is recorded in the trail of a company or a contact (via names your plugin); departments and rows have no history of their own yet.

Why this half arrived late (#229). None of the names above were on the surface until 0.4.0, and every official plugin imported past it anyway — a notifier for Notification, a sync for the records it cannot be handed. A promise that the only working plugin is one that breaks the promise is not a promise, so the missing half is here. It is a wider surface than the first sketch, deliberately: the alternative was a narrow one that was routinely ignored.

Still past the surface, and knowingly so. A settings section is registered through postulo.core.settings_sections, which is not on this list; Adding a settings section below shows the import. Until it is settled, a plugin that adds one is reaching past the promise on purpose.

What the promise is worth

These names keep working across a minor release. A change to one is a ### ⚠️ Deprecated entry first and a ### 🗑️ Removed entry in a later release, never a silent rename. That is the cost of having a surface at all, taken deliberately: without one, #129 would move every shipped plugin into its own package against a boundary nobody had written down, which is how a surface gets set by accident instead of on purpose.

What is not settled yet

What a dashboard widget is handed is an open question. A plugin needing it is reaching past the surface knowingly — and tests/test_plugin_surface.py records every shipped plugin that does, with the reason. That list is the map of what is left to move, and a new entry has to be added on purpose, because the test fails until somebody writes down why.

What a document template may be is settled: Theme and ThemeKind are on the surface, and A theme, if you set documents below is the whole of it.

Today the two built-in sources import nothing from Postulo at all, and the telephone-numbers feature imports only the surface. That is the check that the surface is not so wide as to be meaningless.

The plugins Postulo ships are packages like yours

Every plugin Postulo ships, and they live where yours would (the directories of src/postulo/plugins/ that call shipped()):

src/postulo/plugins/
    builtin/          schema.org and page-metadata, the two capture sources
    email/            the notifier that sends through the instance's mail settings
    smtp/             the transport underneath it
    own_mail/         the outbox that sends as the person rather than as the instance
    localstore/       the store every document is in
    europass/         the importer, and the reader it is a declaration for
    phone_numbers/    the feature that switches several numbers per person on and off
    email_addresses/  the feature that governs a page and owns no data at all
    postal_rules/     what each country calls the parts of an address, the order it writes them in, and what it refuses
    employer_structure/  whether an employer may be more than one company
    social_profiles/  the feature that switches several social profiles per person on and off
    repositories/     the same for code repositories
    websites/         the same for websites -- three switches over one table
    identifiers/      which external identifiers exist, and what each one identifies
    link_services/    which services a web link can be on, and what an address looks like
    messaging_services/  which services a messaging handle can be on, and what a handle looks like
    messaging_contacts/  the feature that switches messaging handles on contacts and on you
    maps/             the companies' map, and placing an address at its postcode or town
    wikidata/         whether a company's Wikidata item may be read, on a press
    register_lookup/  asking a company's register about its number, on a press; and the registers it may ask
    publication_lookups/  whether a publication may be filled in from its DOI or an ORCID record, on a press
    multiple_fields/  the module the "multiple …" features are sub-modules of
    file_formats/     the module PDF, Word, OpenDocument, iCalendar, vCard and GeoJSON are sub-modules of
    gdpr/             what the instance keeps on other people, and the duties that go with it
    browser/          Web Push and the notice a browser tab collects
    builtin_notifications/  the module Browser, Email and Webhooks are sub-modules of
    webhook/          the notifier that posts a signed message to an address you name

Each has its manifest, its locale/, and imports postulo.plugins.api. None of them touches the database: an importer turns bytes into a record, a store writes a file, a notifier sends a message, and Postulo does the ownership scoping. That is not tidiness — scoping done wrong in a plugin is how one person sees another's data, and the way not to get it wrong in seven places is not to need it in seven places.

This is checked, not asserted. tests/test_plugin_surface.py takes the list from the registry rather than from a list in the test, so a built-in added tomorrow is checked tomorrow. It fails on one that lives outside postulo.plugins, one with no catalogues of its own, one that imports a model, and one that imports anything else from Postulo without a written reason.

What a built-in does differently, and why. It registers through register_builtin() rather than an entry point. An entry point would buy nothing — the module is in the same distribution, so the lookup is a slower import — and would cost the ordering docs promises just above: third-party plugins are tried first, and built-ins last, so yours can take precedence over Postulo's. Entry points resolve in one list with no way to say after everything else.

A logo, if you have one

Name a file inside your package and Postulo serves it:

@declares(
    Manifest(
        name="paperless",
        label="Paperless",
        ...
        logo="paperless.png",
    )
)
class PaperlessStore:
    ...

A name, never a path: a separator or a leading dot is refused rather than normalised, and the file is read out of the package with importlib.resources, so nothing you declare can name a file outside it.

SVG is preferred, and PNG, JPEG, GIF and WebP all work. A mark is authored as a vector and the lists draw it at 24 pixels, where being crisp is the whole point — so an SVG is kept as one rather than flattened. A raster file is decoded and written out again as a PNG at its own size, so what is served is an image Postulo produced rather than your file passed through. Under 2 MB in for a raster file and under 512 KB for an SVG; under 1 MB stored.

An SVG goes through a sanitiser, and the reason is privacy rather than script. An SVG is a document: it can carry <script>, onload=, a <foreignObject> full of HTML, and <image href="https://…">. The last one is the one that matters here. This whole mechanism exists so your server never learns which instances run your plugin, and a CDN reference in an exported SVG puts that leak straight back — which happens by accident far more often than by malice, because design tools emit them.

So the sanitiser keeps an allowlist of elements and attributes and drops everything else. In practice: no external references of any kind. href and xlink:href may point at a #fragment in the same file and nowhere else, url() likewise, and there is no <style> element and no style attribute — presentation attributes only. Gradients, clip paths, use, paths and text all survive; a webfont, a CDN image and an @import do not. Draw the mark, embed nothing, and it will come through untouched.

Postulo serves it; you do not. There is no way to give a URL, and that is the point rather than an omission: an <img> at your server would tell you which instances run your plugin, how many people use it, and when. The production policy is img-src 'self' for exactly that reason.

Having no logo is the normal case. The interface falls back to the initials tile it already uses for a person with no picture and a company with no logo, so nothing is ever a broken image — and a declared file that is missing, too large, or not an image falls back the same way, with a line in the log rather than a broken page.

It is not squared for you. Until 0.5.0 every mark was fitted onto a 256-pixel transparent square; now it is kept at the shape you drew it. The square is the interface's job — every place a mark appears draws it object-contain inside a square box — so a wide wordmark letterboxes exactly as it did before, and a mark that wants to be sharp on a large screen can be.

If the logo is somebody else's mark

Do not put it in Postulo, and Postulo does not put one in itself. Displaying a mark to say "this reads Europass files" is nominative use and is what every integration directory does. Shipping the file is a different statement: Postulo is AGPL-3.0, and that licence grants rights to the code — it cannot sublicense a mark the project does not own, so every fork would be redistributing somebody's trademark under a licence with nothing to say about it. The Commission's own reuse decision, which makes its documents freely reusable, excludes logos and trademarks from its scope, so "it is an EU document" is not an answer either.

So none of the plugins Postulo ships carries a logo. The Europass importer says "Europass import", and its name does the identifying. A plugin distributed by whoever owns the mark is a different situation and theirs to decide — this rule is about what this repository redistributes.

The rule has to be the same for every mark, which is why it is written here rather than decided per logo: a rule that only works for the logos a project happens to like is not a rule. Where a mark's owner permits redistribution, the shape to copy is src/postulo/static/flags/LICENSE.txt — the notice travels with the files.

A theme, if you set documents

A theme is a way of setting a CV, a portfolio, a letter, a job-search report, a list exported from a table or the Calendar printed: a template per kind, and the CSS inlined in it. Declare one and Postulo offers it in the picker beside its own. The kinds are ThemeKind.CV, ThemeKind.PORTFOLIO, ThemeKind.LETTER, ThemeKind.REPORT, ThemeKind.LIST and ThemeKind.CALENDAR.

from postulo.plugins.api import Theme, ThemeKind, Manifest, declares


@declares(Manifest(name="vellum", kind="feature", label="Vellum"))
class Vellum:
    themes = [
        Theme(
            name="vellum",
            label=_("Vellum"),
            templates={ThemeKind.LETTER: "vellum/letter.html"},
            provider="Vellum Press",
        )
    ]

Put the templates in a templates/ directory beside your package, exactly as you put your catalogues in locale/. Registering the plugin puts that directory on the path Django looks for templates in, after every directory Postulo and its apps own, so a file of yours at the path of one of theirs (a page, a form widget, an allauth template) is never the one used; nothing is copied and nothing is compiled.

vellum/
    __init__.py
    locale/
    templates/
        vellum/
            letter.html

templates is the declaration. A theme sets what it has a template for, and nothing else. If yours sets letters but not CVs, say so by having only the one file: the CV picker will not offer it, and asking for the pair directly raises CannotRender with a sentence in it rather than a missing-template traceback. A theme that answered for a kind it had never seen by falling back to Postulo's plain would put somebody's Vellum letter beside a plain CV in one envelope, which is the same bad outcome said too late to do anything about.

Your template owes the reader a language and a direction. Postulo hands you document_language and document_direction; a template that ignores them renders an Arabic CV left to right. The simplest way to get this right is to extend Postulo's own base:

{% extends "documents/themes/base_letter.html" %}
{% block page_styles %}…{% endblock %}

which is a template, not a Python import, and is what Postulo's own two themes do.

contact.photo is the person's CV photo as a data: address, or an empty string: it is set only when the CV says so and a photo is kept, and never when the CV's contact details are off. data: is the one address either renderer may open. Print it as <img src="{{ contact.photo }}" alt=""> where your design wants it; the photograph keeps the person's own framing, so frame it with object-fit: cover. A theme that does not read contact.photo prints no photo and is not broken.

Say which papers your theme prints on. Postulo prints on A4 and US Letter, portrait or landscape, and the person chooses (see CVs). Declare the papers in papers, for every kind your theme sets or kind by kind:

Theme(
    name="vellum",
    label=_("Vellum"),
    templates={ThemeKind.LETTER: "vellum/letter.html", ThemeKind.REPORT: "vellum/report.html"},
    papers={
        ThemeKind.LETTER: ("any",),
        ThemeKind.REPORT: ("a4-landscape", "letter-landscape"),
    },
)

Each entry is a paper's key — a4-portrait, a4-landscape, letter-portrait, letter-landscape — or "any" for a fluid theme that prints on every paper Postulo has, including any added later. A plain list (papers=("any",)) is for every kind. A theme that declares nothing is A4 portrait only, and so is a kind a mapping leaves out; a key Postulo does not know is passed over. The paper picker offers only what you declare, says why where that is fewer than four, and an export that asks for another is refused with CannotRender.

Do not write the page's size. Postulo writes one @page { size: … } rule for the paper chosen, last in your document's <head> (with a <meta name="postulo-paper"> beside it, which is how Chromium is told), so a size of your own loses to it. Write your margins in @page as before. Your template is handed the paper as paper, for a fluid theme that wants to adapt to it: paper.key, paper.size (a4, letter), paper.orientation (portrait, landscape), paper.landscape, paper.width_mm and paper.height_mm, already turned. Draw your theme on every paper you declare before you declare it.

A report theme

The job-search report is a kind like the others: a theme with a template for ThemeKind.REPORT is offered on the report's page, beside File it as a PDF, and draws both the PDF that address hands back and the one the press files under Sent documents.

Theme(
    name="ledger",
    label=_("Ledger"),
    templates={ThemeKind.REPORT: "ledger/report.html"},
    provider="Ledger Press",
)

A report has no row of its own, so the choice is not kept on the document: it is remembered per person, the one they last pressed the button with, and offered first next time. A name nothing answers to any more — your plugin was removed or switched off — is drawn in plain, and the report is never refused for it.

A report theme is handed plain values, never a model: strings, numbers, dates, and lists and mappings of them. These are the names:

Name What it holds
person The person's name as Postulo shows it
office, adviser The public employment service they recorded and their adviser there, or empty strings
produced_at When the report was drawn, a date and time in the person's zone
period label (March 2026, in the report's language), start and end (dates, both included), kind (month, quarter, weeks or custom) and days
total Applications sent in the period
drafts Applications drafted in the period and never sent, which the report leaves out
through_service Applications made through the office's own listings
appointments_attended Meetings with an adviser attended in the period
cadence weeks (each with start, end, label, count, empty, partial — reaching outside the period — and in_progress), total, per_week, longest_gap (days), current_run (weeks in a row up to the last), empty_weeks and busiest (the tallest week, never 0)
happened replies, interviews, offers and rejections in the period, counted by when they happened
evidence One row per application sent, oldest first: applied_on, company, role, source, url, status (in words) and last_activity_on (a date, or none)
sources Where they were found: name and count, largest first
industries By sector, largest first: name, count, code (the NACE section, or empty) and children, the industries in it, each a name and a count

and, as for every theme, document_language, document_direction, document_title and csp_nonce. Who referred somebody, through which agency and why an application ended are in the spreadsheet and not in this context: a report is handed to an employment office, and those are the person's own business.

Whether to print the industries is your theme's choice; Postulo's own two do not, so a report looks as it always did to somebody who never picks a theme. The labels — the period, the weeks, the statuses, the sectors — arrive already in the language the report is drawn in, which is chosen on the report's page and is the same whether the page or the worker draws it.

Postulo's own report templates extend documents/themes/base_report.html and fill its page_styles block, which you may do too; the CSV is not a template and no theme sets it.

### A list theme

*Applications*, *Companies* and *Listings* each have an **Export** button, which writes the
table as it is at that moment — the person's columns in their order, its filters and its
sort, every row rather than the page on screen — as a PDF or a CSV. A theme with a template
for `ThemeKind.LIST` is offered for the PDF, on the export page, beside Postulo's own:

```python
Theme(
    name="roster",
    label=_("Roster"),
    templates={ThemeKind.LIST: "roster/list.html"},
    papers={ThemeKind.LIST: ("a4-landscape", "letter-landscape")},
    provider="Roster Press",
)

One kind for every list. Your template is handed the same names whichever table it came from, so it sets applications, companies, listings and any table Postulo adds later, without your knowing which. If you want to, list says which it is.

Like a report, a list has no row of its own: the theme is remembered per person, the one they last downloaded a PDF with, and a name nothing answers to any more is drawn in plain. A list is wide, so with nothing chosen it is drawn on the person's default paper turned on its side, where your theme prints on that.

A list theme is handed plain values, never a model: strings, numbers, booleans, a date and time, and lists and mappings of them. These are the names:

Name What it holds
list Which table it is: applications, companies or listings
title The table's name in words, in the document's language (Applications)
person The person's name as Postulo shows it
produced_at When the list was drawn, a date and time in the person's zone
filters What narrows it, in words: each a label and a value (Status, Applied; Applied, from 1 March 2026 to 31 March 2026). A tick box has an empty value, since its name is all it says. A listings export always starts with the tab (Showing, To decide)
sort column (its label), descending and words (Sorted by Applied, highest first); empty where the table's order is not a column
columns The person's columns, in their order: each a key, a label and numeric
rows One list per row, one string per column in the order of columns: the cell as the table says it, already in the document's language — dates in its format, a status in its words, a list of tags or industries separated by commas. An empty cell is an empty string
count How many rows are in rows
total How many rows the table answered with
limit The most rows a PDF holds (1,000)
truncated Whether total is more than count — say so, as Postulo's own do
left_out The labels of any chosen column the export does not write; none today

and, as for every theme, document_language, document_direction, document_title, csp_nonce and paper.

Postulo's own list templates extend documents/themes/base_list.html and fill its page_styles block, which you may do too. The CSV is the same rows and is not a template; no theme sets it.

A calendar theme

The Calendar has a Print button beside its views, which prints the view on screen — the month, the week, the day or the agenda, with the kinds its key shows and its search — as a PDF. A theme with a template for ThemeKind.CALENDAR is offered on the page it opens, beside Postulo's own:

Theme(
    name="planner",
    label=_("Planner"),
    templates={ThemeKind.CALENDAR: "planner/calendar.html"},
    papers={ThemeKind.CALENDAR: ("any",)},
    provider="Planner Press",
)

One kind for the four views. Your template is handed the same names whichever view it is; view says which, and it is yours to draw a month as a grid and the rest as days one under another, as Postulo's own do, or otherwise. Every entry of the period is there: the screen's month shows two a day and counts the rest, a printed one should not.

Like a report and a list, a calendar has no row of its own: the theme is remembered per person, the one they last downloaded a PDF with, and a name nothing answers to any more is drawn in plain. A month is seven columns wide, so with no paper chosen it is drawn on the person's default paper turned on its side, where your theme prints on that; a week, a day and an agenda on the default as it is.

A calendar theme is handed plain values, never a model: strings, numbers, booleans, dates, dates and times, and lists and mappings of them. These are the names:

Name What it holds
view month, week, day or agenda
view_label The view's name in the document's language (Month)
title The period's name as the page says it (September 2026, Week of 14 Sep 2026, 30 days from 1 Sep 2026)
person The person's name as Postulo shows it
produced_at When it was drawn, a date and time in the person's zone
start, last The period's first and last day, both included; a month is whole weeks, as on screen
kinds The kinds shown, in words (Interviews, Reminders, …), in the key's order
narrowed Whether the key had some kinds switched off — say which, as Postulo's own do
search What the page's search narrowed it to, or an empty string
weekdays The seven weekday names in full, in the grid's order (the locale's first day first), for a month and a week; empty for a day and an agenda
weeks For a month and a week, the days of days in rows of seven; empty for a day and an agenda
days Every day of the period, in order, the empty ones too: date, outside (a month's day from the month before or after), today, and entries, soonest first. An agenda on screen leaves out the days with nothing on; whether yours does is your choice
count How many entries there are in all

An entry is kind (interview, reminder, appointment, deadline, closing or answer), title and detail (the page's two lines, already in the document's language), all_day (a deadline, a closing date or an answer due: no time), starts_at and ends_at (dates and times in the person's zone, ends_at empty where it has no end; both empty for a whole day), over (drawn struck through: a reminder done, an interview cancelled or not come to, a deadline already sent, a listing already decided, an offer already answered), state (no_show for an interview nobody came to) and said — what the page says before the title for a reader who cannot see a colour (Done reminder:, Cancelled interview:), empty where the title says it. A printed page may be grey: print said rather than relying on a colour or a strike.

They are the values calendar_items hands a calendar plugin, with every whole day included whether or not POSTULO_PUBLIC_URL is set — a page on paper names nothing in anybody's calendar.

And, as for every theme, document_language, document_direction, document_title, csp_nonce and paper. Postulo's own calendar templates extend documents/themes/base_calendar.html and fill its page_styles block, which you may do too.

Your own files: fonts, drawings and images

The renderer fetches nothing while it draws (#163), so a file your theme wants has to be in the document already. Keep it in your package and let Postulo write it in:

{% load theme_assets %}
<style{% if csp_nonce %} nonce="{{ csp_nonce }}"{% endif %}>
  {% theme_asset "fonts/Vellum-Regular.woff2" family="Vellum" weight="400" style="normal" %}
  body { font-family: "Vellum", serif; }
</style>
…
<header>{% theme_asset "images/logo.svg" %}</header>
<img src="{% theme_asset "images/paper.webp" %}" alt="">
vellum/
    __init__.py
    fonts/
        Vellum-Regular.woff2
    images/
        logo.svg
        paper.webp
    templates/
        vellum/
            letter.html

{% theme_asset %} takes a path inside your package, with / between its parts, and writes the file in the form the document needs. What the file is decides which, read from its first bytes, never from its name:

  • an SVG is written as markup, sanitised by the allowlist Postulo's own pictures use: no <script>, no on… handler, no <foreignObject>, no <image>, no reference out of the file (<use href> to a #fragment only, url(#…) only). Its <style> rules and style= attributes become presentation attributes on the elements they match, and its ids get a prefix of their own so two drawings cannot paint with each other's gradients. Shapes, paths, text, gradients, clip paths and masks survive; filters, patterns and markers do not;
  • a PNG, JPEG, WebP or GIF is written as a data: address, for an src or a url();
  • a WOFF2, WOFF, TrueType or OpenType font is written as an @font-face rule with a data: source, so put it inside your <style>. family is required (letters, digits, spaces, - and _); weight is normal, bold, 100…900 or a range such as 100 900; style is normal, italic or oblique.

Where it reads from. The package your plugin class is defined in (the module itself if it is a package, the package holding it if not), whatever Theme(package=…) says: registration sets it. The file is read through importlib.resources, so a wheel or a zipped package works. A .., an absolute path, a drive letter, a backslash, a hidden file and a symbolic link out of the package are refused.

Declare the scripts each font draws:

Theme(
    name="vellum",
    label=_("Vellum"),
    templates={ThemeKind.LETTER: "vellum/letter.html"},
    font_scripts={"fonts/Vellum-Regular.woff2": ("Latn", "Grek")},
)

ISO 15924 codes, the ones a language tag uses. A font missing from font_scripts is not written. A declaration is not a glyph: Postulo also asks the font's own character map for the character check_fonts asks the server's fonts for, and a document in a script your font does not both declare and draw gets no @font-face for it, so your font-family list falls through to the server's fonts: the whole document in one of them, never a box. End the list with a generic family (serif, sans-serif) for that reason. manage.py check_fonts and Server settings → Overview list each theme font and the scripts it declares, a declared script it does not draw in red.

Two ceilings, because every drawn PDF and every filed document's HTML carries what the theme inlined, as base64, a third larger: 1 MiB for one file (MAX_THEME_ASSET_BYTES) and 3 MiB for all the files one document inlines (MAX_THEME_DOCUMENT_BYTES), counted as the files are read, each time one is written. Subset your fonts to the scripts you declare.

A file that is refused writes nothing, and the reason goes to the log as a warning from postulo.documents.theme_assets; the document still draws. Check your files in your own tests with load_theme_asset(package, name), which raises ThemeAssetRefused with the same reason.

The preview shows what the PDF shows, because it is the same HTML. Its content security policy, and no other page's, allows a data: font. An SVG's styles need no nonce, being attributes by then; your own <style> still needs the csp_nonce shown above.

What you may not do is ship markup somebody else wrote at runtime. There is no upload form for themes and there will not be one: rendering executes the template, so an uploadable theme is remote code execution with a file picker on it. Your templates run because an administrator installed your plugin, having read its author, licence and source — the same trust that already lets it hold their credentials, not a new one.

Names. Postulo's own — plain and classic — are not yours to take, and the first plugin to claim any other name keeps it; a second is logged and ignored. Sixty characters is the limit, because a column has to hold it.

provider is shown to the person choosing. The picker reads "Vellum, from Vellum Press" where you give one, and just the label where you do not. Postulo's own carry none, because there is nothing to distinguish them from — but a theme of yours is markup that runs when somebody exports a document, and whose it is belongs beside the name rather than in a page they would have to go looking for.

A document format, if you write one

A CV and a letter can be downloaded as plain text, OpenDocument and Word. A portal that asks for something else is a format of yours: a function from an Outline to bytes.

from postulo.plugins.api import DocumentFormat, register_document_format


def write(outline):
    ...  # outline.title, outline.blocks, outline.language, outline.direction


register_document_format(
    DocumentFormat(
        key="rtf",
        label=_("Rich text"),
        extension="rtf",
        content_type="application/rtf",
        write=write,
        has_properties=True,
        provider="Vellum Press",
    )
)

Call it where your plugin is loaded. The format is then on the In other formats list of every CV and on a letter's As a file, with no page edited.

A format is given the words, not the page. An Outline holds headings (levels 1 to 3), paragraphs and lists in the order the document says them, with the theme taken off. It never sees the theme. A format that wants the page is a theme, or a PDF.

The properties choice is already applied. Where the person left the properties out, the outline you are handed has no author, subject or keywords and a neutral title; where they edited them, it carries what they typed. Say has_properties=True if the file you write has somewhere to put them, and write outline.title, author, subject, keywords and language there, escaped for your format.

A key already taken is refused, as a theme's name is: a plugin that could replace docx would change what every Word file contains. If write raises, the person gets a sentence and the other formats still work; the log has the traceback.

Saying who you are

Everything a plugin says about itself goes in one place: a manifest.

from postulo.plugins.api import Manifest, declares


@declares(
    Manifest(
        name="acme-board",
        label="ACME Board",
        version="1.4.0",
        kind="source",
        description="Reads postings from ACME's board.",
        author="First Last <first.last@example.org>",
        licence="AGPL-3.0-or-later",
        source_url="https://example.org/your-plugin",
        logo="logo.svg",
    )
)
class AcmeBoardSource:
    def can_handle(self, url): ...
    def parse(self, url, html): ...

@declares attaches the manifest and sets name, version, kind, label and description on the class from it, so the protocol members the registry reads and the manifest cannot disagree. You may write them by hand instead; the manifest is optional and so is the decorator.

name is the one field that is not free to change later. It is what the registry keys on and, for a source, the value written into every capture's source field — renaming it orphans the history of every capture anybody made with your plugin.

logo names an image file inside your package, which Postulo serves beside your plugin's name; it is a file name and never an address. See A logo, if you have one.

A manifest is optional, and so is every field but name. A plugin that declares nothing still loads, and shows its identifier where a name would be. That is not politeness: the protocols are runtime_checkable, which means they check data members as well as methods, and registry.py drops anything that fails isinstance — so making any of this required would silently unload every plugin written before it existed. One optional attribute carrying any number of facts is what makes the set extensible at all.

Your packaging is the fallback. Postulo reads your wheel's metadata at install time and keeps it, and base.manifest_of fills in anything your manifest left out from there. So a plugin that declares nothing but packages itself properly still shows an author and a licence on Server settings → Plugins:

[project]
description = "One sentence about what this does."
license = "AGPL-3.0-or-later"
authors = [{ name = "First Last", email = "first.last@example.org" }]

[project.urls]
Source = "https://example.org/your-plugin"

Both halves of authors matter: with an email it becomes Author-email: First Last <address>, without one it is just a name. The URL is read from Project-URL — Source first, then Repository, then Homepage — because Home-page is setuptools' old field and modern backends do not write it.

Read it all back with base.manifest_of(plugin), which is the one place anything in Postulo asks who a plugin is. base.label_of and base.description_of are shorthands over it.

The plugins Postulo ships use the same machinery, through a shipped() helper that fills in the author, the licence, the source URL and Postulo's own version — because a built-in claiming an independent version number is inventing a fact, and one that ships with the application changes when the application does. A test walks every plugin that calls it and fails on a missing field, so this is not a rule the project asks of you and not of itself.

Identifier registries

An identifier plugin says which external identifiers exist — ORCID, ISNI, Wikidata, a legal-entity identifier, a national company register number — what each should look like, where it links, whether its check digits add up, and which subjects it identifies:

Scheme(ORCID, "ORCID", pattern=..., subjects={"person"})
Scheme(LEI, "LEI", pattern=..., subjects={"company"})
Scheme(ISNI, "ISNI", pattern=..., subjects={"person", "company"})

That last field is the whole point of the kind. Two registries kept two sets of choices, and the sets were not disjoint: ISNI identifies contributors and organisations by its own definition, Wikidata has items for both, LinkedIn has profiles and company pages. Keeping them apart had quietly picked a side for each, so a researcher could not record their Wikidata item and a university could not record its ISNI (#109).

A scheme may name an icon: a Lucide icon Postulo ships, drawn beside the scheme in the list that chooses it. It must be generic -- a key, a building, a book -- never the registry's own mark. Without one the list draws an identity card. A scheme may also name a brand, one of the marks Postulo ships in src/postulo/static/brands/ (assets/brands.txt), which is drawn where an identifier is shown (a company's page, for now) and never in that list; a name that is not one of them draws nothing, so the scheme's name alone identifies it (#654). A mark is drawn in its owner's colour or, where the owner's own guidelines allow it, in black or white, as NOTICE.txt beside the marks records; a plugin cannot add a mark to the core's set.

A scheme owns no rows, which is why this can be a plugin at all: PersonIdentifier and CompanyIdentifier are core models, migrated by core, and a plugin contributes only the vocabulary. A plugin that wanted to own a table could not, and that is a real limit rather than an oversight.

It cannot be switched off. Every other kind answers is this on for this person; a registry answers what does this key mean. Off would leave every stored identifier without a label, a link or a check, which is not what off means anywhere else here — so identifier is ungoverned, alongside transports.

It cannot reach the network, and neither may yours. A scheme validates what somebody typed and knows where it links; a checksum catches the typos a lookup would, which is the entire reason ORCID and the LEI have one.

No third-party group is advertised yet, and the kind is internal for now. That is not shyness about the idea — register is one generic scheme for SIRET, NIF, Companies House, KvK and Handelsregister alike, and a plugin per country is the obvious next thing — but a third-party contract is a promise about breakage, and this one is not written.

An instance adds schemes without a plugin (#311): an administrator writes them in JSON under Server settings → Plugins, and they join the registry after the ones Postulo ships (Configuration has what the JSON can say). The plugin Postulo ships reads that text — Identifiers.defined(text, taken=…) answers with the schemes in it and what is wrong with the rest — and still owns no row: the text is kept on Postulo's policy row and handed over. Four things follow for anybody reading the registry:

  • a scheme's label may be a lazy string that reads in the reader's language, so take str() of it when it is drawn and not before;
  • such a scheme carries quoted, and url_for percent-encodes the value into its link, and max_length, past which its pattern is never asked. Its pattern is compiled from a small language that reads a value once, not written as a regular expression;
  • a row may be stored under a key the registry no longer knows, because deleting a scheme deletes no identifier. label_for answers with the key and url_for with nothing, as they always have for a key nothing recognises. The model lets such a row be saved while it keeps that key, and refuses a new row under it;
  • a row may hold a value its scheme would refuse today, because changing a scheme refuses no identifier either. A row whose scheme and value are the ones the table holds passes the model's clean untouched (is_as_stored), and is checked when either changes. Its kept_as_it_was is the sentences today's scheme says against it — empty for a row that fits — and its url is empty while there are any. An importer asks as_restored(key, label, subject, value=…), which answers Other, named by the key, for a key this instance lacks and for a value its scheme refuses.

What a reading hands over is checked before any page sees it. The registry encodes each scheme's key, label, example and links once, and leaves out, and logs, a scheme that fails: nothing the registry gives a page may be able to raise when it is drawn. A label in several languages (in_languages) checks every one of its names when it is made.

A link-service plugin says which services a social profile, a code repository or a website can be on, and what an address on each one looks like. Postulo ships fifteen of them in a plugin of its own, postulo.plugins.link_services, written exactly as yours would be.

[project.entry-points."postulo.link_services"]
example = "postulo_example:ExampleServices"
import re

from postulo.plugins.api import LinkKind, LinkService, Manifest, declares


@declares(Manifest(name="example-services", kind="link-service", label="Example services"))
class ExampleServices:
    services = (
        LinkService(
            "elsewhere",                      # what a row stores; never changes
            "Elsewhere",                      # its name, as its owner writes it
            LinkKind.SOCIAL,                  # which block offers it
            re.compile(r"/u/(?P<handle>[^/\s]+)(?:/.*)?"),
            hosts=("elsewhere.example",),
            icon="user",
            example="https://elsewhere.example/u/name",
            prefix="https://elsewhere.example/u/",   # optional; see below
        ),
    )

A prefix (#678) is the address up to where a person's own name goes, as the service writes it. A row on a service that has one asks for the name alone, with the prefix shown in front of it and fixed, and stores prefix + name: the stored form is still the whole address. It has to be https, on a host, and end in / or @ (https://www.threads.com/@); one that is not is read as blank, and so is the default, which suits a service on any host (a Mastodon server, somebody's Forgejo) and every repository. Keep it to where a name follows at once: https://www.linkedin.com/in/ is a profile, and a company's page, which is not, stays an address as stored. The pattern should accept prefix + "name" and read name back as its handle.

The pattern reads the path and nothing else, percent-decoded and matched whole. The host is checked apart from it, and a subdomain of a listed host counts, so www. needs nothing said; the query string and the fragment are never read. A group named handle is what an unnamed link is called by (Elsewhere name); without one it is the whole path. Be permissive at the end and strict at the start: a real address refused sends somebody to Other for no reason they can see.

What a pattern must be. Compiled over text — re.compile(r"…"), never rb"…" — and written so that it reads an address once. It is run on whatever somebody pastes, inside their request, and nothing times it out: the only bound is the length of an address, 500 characters. A repetition inside a repetition — (a+)+, (?:[^/]+/?)+, (\w+\s?)* — can take time that doubles with every character, and 500 characters of the wrong address is then a worker that never answers again, for everybody on the instance. So:

  • repeat a character class, never a group that itself repeats: [^/\s]+(?:/[^/\s]+)?, not (?:[^/\s]+/?)+;
  • where a path has parts, separate them with a character the part cannot contain (/), so there is one way to divide the path and not many;
  • where what follows does not matter, end with (?:/.*)? and not with a repeated group;
  • no backreferences, and no alternation inside a repetition whose branches can match the same text.

Postulo cannot tell a slow pattern from outside and does not try: a plugin's patterns are its author's to keep. Postulo's own are held to this by a test that times each over almost-matching paths of up to 500 characters (first_slow_path in tests/test_link_services.py); it is a few lines, and worth copying into your package's tests.

What your pattern is never handed. An address that is not http or https; one longer than 500 characters; one with anything before an @ in its authority, or a backslash there — a browser and the server read different hosts out of those, and no profile's address has either; and one whose path holds a control character once decoded (%00, %1B). Each is Other when pasted with nothing chosen and refused under a service chosen by hand, whatever the pattern would have said, so a pattern need not guard against them.

hosts or any_host=True, and usually not both. A service on hosts nobody can list — a federated network, software people run themselves — sets any_host, and an address anywhere is then accepted on its shape. hosts beside it is only where the service is recognised without being asked: a shape alone is never guessed from.

The icon is a name out of the Lucide set Postulo ships (assets/icons.txt). A name it does not have draws the kind's own icon rather than failing the page. A select always draws the icon. A service may also name a brand, one of the marks Postulo ships in src/postulo/static/brands/ (assets/brands.txt), which is drawn where a link is shown and falls back to nothing, so the icon alone, when the name is not one of them. A plugin cannot add a mark to the core's set; for its own logo see If the logo is somebody else's mark.

There is always Other, and it is not yours to define. A row with no service is Other; a key of other is refused. So a registry can add to what is offered and can refuse nothing that was accepted without it.

Postulo's own keys are Postulo's. A package that offers linkedin is logged and that entry left out, or installing it would change what the word means on every row already stored. That holds for a class that inherits from the plugin Postulo ships as well: shipped means an object of exactly the class Postulo registered. Between two packages the first to claim a key keeps it. A service that cannot be used — a key that is not lower-case letters, digits and hyphens, a kind no block offers, nowhere to live, a pattern compiled over bytes, an icon that is not a string — is logged and left out, and the rest of the table stands.

A service owns no rows. core.WebLink is a core model, and service on it is your key. Remove the plugin and its links are shown as Other and keep the key, so they are yours again when it comes back.

It cannot be switched off per person, for the identifier registry's reason, and it must not reach the network: a service says what an address looks like, and Postulo never opens a link on its own.

Messaging services

A messaging-service plugin says which services a messaging handle can be on, and what a handle there looks like. A handle is the identifier a service gave somebody (a Matrix ID, a Signal username, a Telegram name) and not an address on the web, which is why this is a registry of its own and not a fourth kind of link. Postulo ships five (Matrix, XMPP, Signal, Telegram, Threema) in a plugin of its own, postulo.plugins.messaging_services, written exactly as yours would be. A kind registers in the postulo.messaging_services entry-point group.

[project.entry-points."postulo.messaging_services"]
example = "postulo_example:ExampleChat"
import re

from postulo.plugins.api import Manifest, MessagingService, declares


@declares(Manifest(name="example-chat", kind="messaging-service", label="Example chat"))
class ExampleChat:
    services = (
        MessagingService(
            "examplechat",                    # what a row stores; never changes
            "ExampleChat",                    # its name, as its owner writes it
            re.compile(r"[a-z0-9_]{3,32}"),   # a handle there, matched whole
            normaliser=lambda text: text.strip().lstrip("@").lower(),
            example="@name",                  # for the sentence that refuses a handle
            icon="at-sign",
        ),
    )

The pattern is matched whole against the handle once it is normalised. The normaliser is what is stored for what was typed: it defaults to stripping a leading @, and a service folds the case where it folds it (Matrix and XMPP lower-case, Threema upper-cases) or drops what is not part of the handle (an xmpp: prefix, a /resource). Normalising is also a plugin's code run on somebody's request: one that raises is logged and its handle refused.

What a pattern must be. The same as for a link service: compiled over text, and written to read a handle once. Nothing times it out; the only bound is the length of a handle, 255 characters, and a handle longer than that is not read at all. A repetition inside a repetition ((a+)+) takes time that doubles with every character. Postulo's own patterns are held to this by a test; yours are yours to keep. A handle with a control character in it is refused whatever the pattern says.

The icon is a name out of the Lucide set Postulo ships (assets/icons.txt), and never a brand's mark; a name Postulo does not have draws the generic icon. Postulo ships no marks for messaging services.

There is always Other, and it is not yours to define. A row with no service is Other: the person names the service and types any text for the handle, up to 255 characters. A key of other is refused, and a handle is never recognised as a service by its shape alone.

Postulo's own keys are Postulo's. A key the plugins Postulo ships already hold is logged and left out of yours, and between two packages the first to claim a key keeps it. A service that cannot be used (a key that is not lower-case letters, digits and hyphens or is longer than 40 characters, a pattern compiled over bytes, a normaliser that cannot be called) is logged and left out, and the rest of the table stands.

A service owns no rows. core.MessagingHandle is a core model, and service on it is your key. Remove the plugin and its handles read as Other and keep the key, so they are yours again when it comes back. It cannot be switched off per person, and it must not reach the network: a service says what a handle looks like, and nobody is ever asked whether it exists.

Company registers

A register-adapter plugin tells Register lookup how to ask a country's company register about one number, and how to read what it answers. Postulo ships five countries and GLEIF in postulo.plugins.register_lookup, written exactly as yours would be; a package for another country registers in the postulo.register_adapters entry-point group.

[project.entry-points."postulo.register_adapters"]
example = "postulo_example:ExampleRegisters"
from postulo.plugins.api import (
    Manifest,
    RegisterAdapter,
    RegisterAddress,
    RegisterRecord,
    declares,
)


def read(document, number):
    """The JSON the register answered with, as a record; None for "no such entry"."""
    if document.get("id") != number:
        return None
    return RegisterRecord(
        number=number,
        name=document.get("name", ""),
        address=RegisterAddress(
            street=document.get("street", ""),
            postcode=document.get("postcode", ""),
            municipality=document.get("town", ""),
            country="XX",
        ),
        state="active",          # "active", "administration", "dissolved", or ""
        state_words="Registered",  # the register's own word for it
        activity="62.01",        # its code, as the register writes it
        division="62",           # the NACE division, where the code is NACE's
    )


@declares(Manifest(name="example-registers", kind="register-adapter", label="Example registers"))
class ExampleRegisters:
    adapters = (
        RegisterAdapter(
            key="xx-example",                       # lower-case letters, digits, hyphens
            label="Example Register",               # drawn as "Look up in Example Register"
            scheme="register",                      # or "lei", with country=""
            country="XX",                           # the two letters a number begins with
            hosts=("api.register.example",),
            addresses=lambda number: [f"https://api.register.example/companies/{number}"],
            read=read,
            source="Example Register Office",       # named beside what came back
            licence="CC BY 4.0",
            licence_url="https://creativecommons.org/licenses/by/4.0/",
            page=lambda number: f"https://register.example/{number}",
        ),
    )

You never make the request. addresses(number) returns the address or addresses to ask, in order, and Postulo asks them through the public-only client: https only, on one of your hosts and nowhere else, with Postulo's user agent, a deadline, two megabytes at most and no redirects. A 404 moves on to your next address; read returning None does too. An address outside hosts refuses the lookup. headers may add what the register's documentation asks for, such as a versioned Accept. number is what follows the country and its space in the stored value (552081317 for FR 552081317), or the LEI.

What you return is cleaned anyway. Every string is normalised, stripped of control and direction characters and cut to its column; a country is kept only if Postulo knows it, a division only if NACE has it, a state only if it is one of the three. Nothing is kept until the person ticks it on the review page.

A country Postulo answers for stays Postulo's. An adapter for a scheme and country an earlier one already holds is logged and left out, and so is one that cannot be used (a key of the wrong shape, a host that is not a name, a country that is not two capitals). It cannot be switched off on its own: whether anything is asked is the Register lookup feature's decision.

Importers

An importer reads a person's career out of a file they upload — the mirror of a source, for a different input and a different output. Europass ships as one, and Europass is not the last CV format: JSON Resume, HR-XML, a LinkedIn export, a PDF somebody wants parsed. Each is somebody's own itch, and the person who cares about one should not have to wait for this project to accept a patch about it. So the group is open (#105).

[project.entry-points."postulo.importers"]
json-resume = "postulo_jsonresume:JsonResumeImporter"
from postulo.plugins.api import ImportRefused, Manifest, Record, declares


@declares(Manifest(name="json-resume", label="JSON Resume", kind="importer", ...))
class JsonResumeImporter:
    def can_handle(self, data: bytes, filename: str = "") -> bool:
        head = data[:4096].lstrip(b"\xef\xbb\xbf").lstrip()
        return head.startswith(b"{") and b'"basics"' in data[:4096]

    def read(self, data: bytes) -> Record:
        try:
            document = json.loads(data)
        except ValueError as error:
            raise ImportRefused(_("That is not readable JSON: %(error)s") % {"error": error})
        record = Record(source="json-resume")
        for job in document.get("work", []):
            record.experience.append({...})
        return record

The identity fields are the ones every plugin declares — see Saying who you are — and kind is "importer". The two methods are the contract:

can_handle(data, filename="") -> bool answers what is this, not is it any good. It is handed the whole file and the name it arrived under, and it should answer from the shape of the bytes rather than from the name: an export arrives called all sorts of things, and somebody who renamed it has not thereby changed what it is. A truncated export is still an export, and saying "not readable JSON" from read helps far more than "nothing here reads that" from here. Importers are asked in order — yours before the built-in one, as with sources — and the first to say yes is the one that reads.

read(data) -> Record turns the bytes into a Record, which is on the surface. A record is a career in Postulo's terms rather than the file's, so that every importer fills the same one and the review screen and the writer need to know about none of them. Its lists hold plain dictionaries; these are the keys the writer reads, and a missing key is an error rather than a blank:

List Keys
experience role, organisation, location, start_date, end_date, summary — an entry with no start_date is skipped, and skipped says so
education qualification, institution, location, start_date, end_date, grade, highlights
languages name, proficiency — a CEFR level in lower case, a1 to c2, or native, or "" for a level the file does not state; never guess one — and optionally code, the language as a BCP 47 tag (en, pt-BR) where the file gives one. With a code the entry is chosen rather than typed, and a CV prints the language's name in the CV's own language; Postulo keeps a code it can name and drops one it cannot (a sign language), and where there is none it looks for the one language the name is, so a reader that only has names need do nothing. name stays what the file called it
skill_groups name, and skills, a list of names
projects name, and optionally summary
memberships organisation — as the file words it, never split into a role and a body by a guess — and optionally role, start_date, end_date (none with a start means still), summary and url; an entry with no organisation is skipped, and only a web address (http:// or https://) is kept as the url

Dates are datetime.date or None. person is a dictionary of what the file says about the person — first_name, last_name, headline, location, website, phone, orcid, and address as a dictionary of street, postcode, municipality, region and country (two letters) — every key optional, and each used only to fill a blank on the profile, never to overwrite. locale is the language the file says it was written in, as the file wrote it; leave it blank if the file does not say. skipped is a list of sentences about what was in the file and could not be read, shown to the person before anything is written, and source is your name for the format.

phone is read against its country's numbering plan when it is written to the profile, so a reader hands over what the file says — the dialling code and the digits after it — and does not decide what a leading zero means.

Raise ImportRefused for anything you will not read; the message goes to the person who chose the file, so write it for them. Any other exception is a bug in the importer: one raised in can_handle() is logged and the importer is skipped, and one raised in read() is logged and the person is told that the importer failed while reading the file and that nothing was imported, rather than meeting a server error.

Two rules matter more here than anywhere else in this document.

Postulo refuses the file before you see it. refuse_unreadable rejects an empty upload, anything over MAX_IMPORT_BYTES, and a DOCTYPE in anything that looks like XML — which is where entity expansion lives. That belongs to the kind rather than to each importer, because an importer is handed a file a stranger's browser uploaded, and "every plugin author remembers" is not a control. It runs before can_handle is asked, so your importer never sees a file that failed it. If you parse XML yourself, still use a parser that resolves no entities: the check refuses a declared DOCTYPE, not every trick a parser has.

An importer writes nothing. It turns bytes into a record and stops. What reaches the database is decided on the review screen, by the person, which is the same rule a source obeys and matters more here: a source that guesses wrong costs somebody a few seconds, and an import writes a career. It is also what keeps an importer from needing ownership scoping of its own: it never sees whose file it is.

Testing yours. read takes bytes and returns a dataclass, so most of a test suite needs no Django at all. For the page, register the importer for one test with registry.register_builtin("importer", MyImporter) and post a file to resume:europass_import — the page asks the registry rather than naming Europass, and the record your importer returned is what the review screen shows. tests/test_importers.py in Postulo's own repository does exactly this with a test double.

Transports

A transport carries a message off this machine. Postulo ships one, SMTP, and names no vendor.

The reason the kind exists: a self-hoster whose provider blocks outbound 25, 465 and 587 — which is most residential connections and several hosts — has no route today except finding a relay that speaks SMTP. A transport lets them install one that speaks an HTTP API instead.

[project.entry-points."postulo.transports"]
carrier = "postulo_carrier:CarrierTransport"
class CarrierTransport:
    name = "carrier"
    version = "1.0"
    kind = "transport"
    label = "Carrier"
    description = _("Delivers over Carrier's HTTP API.")

    def config_fields(self) -> list[FieldSpec]: ...
    def test(self, config: dict) -> TestResult: ...
    def deliver(self, messages: list, config: dict) -> int: ...

deliver takes Django EmailMessage objects and returns how many went, which is the contract an email backend already has — so a transport can be a thin wrapper around one where that is the honest implementation. Raising is a failure, and the caller reports it.

A transport is not a notifier, and the difference is worth keeping. A notifier decides that something is worth telling somebody and writes the words; a transport gets those words to them. One sits on the other. Merging them would make "when should Postulo tell me things" and "how does this instance reach the outside world" the same form, and they are not: the first is a person's preference, the second is the operator's plumbing.

Three things follow from that.

Installing yours does not redirect the mail. The registry prefers third-party plugins for sources — a plugin written for one job board knows more about it than a general parser does — and that argument does not transfer to where an instance's mail goes. An administrator chooses, on Server settings → Email, and until they do the built-in one carries it.

Your settings are drawn from config_fields(), on that page, exactly as a connection's are, with secret=True fields encrypted at rest and never shown back. SMTP is the one exception: its settings are named columns that predate the kind, each overridden individually by its own environment variable, and a fresh instance has to be able to send a verification email before there is a row in the database to read.

The per-person plugin policy does not apply. None of available, unavailable, forced on or forced off means anything about mail delivery, and forced off would be an account nobody can recover. Postulo refuses all four for a transport rather than merely leaving them off the page.

The lock. A transport may not be switched off — nor its package removed — while it is the last way anybody could get back into their account. That is evaluated, not hardcoded to a name: recovery_routes() lists the ways in that exist, and while removing yours would empty that list, the refusal stands and says whose accounts it is protecting. Add another route and the lock opens by itself.

Backup destinations

A backup destination keeps the instance's backups somewhere besides its own disk: an object store, a WebDAV folder, another machine. Postulo ships none and names no provider; an administrator installs one and chooses it under Server settings → Backups → Choose the destination.

[project.entry-points."postulo.backups"]
vault = "postulo_vault:VaultDestination"
from postulo.plugins.api import (
    BackupArchive, BackupManifest, BackupRef, FieldSpec, TestResult, client,
)


class VaultDestination:
    name = "vault"
    version = "1.0"
    kind = "backup-destination"
    label = "Vault"

    def config_fields(self) -> list[FieldSpec]: ...
    def test(self, config: dict) -> TestResult: ...
    def send(self, archive: BackupArchive, manifest: BackupManifest, config: dict) -> BackupRef: ...
    def list(self, config: dict) -> list[BackupRef]: ...
    def delete(self, reference: BackupRef, config: dict) -> None: ...

What you are handed is already encrypted. Postulo encrypts each archive with age to the operator's recipient before your plugin sees it, into a file of its own that is deleted when send returns. archive.name ends in .age; archive.chunks() streams it and archive.open() gives a binary file to read. There is no path to reach for, and no way to the unencrypted archive beside it. manifest is plain values for naming and logging; do not store it next to the archive, because it is not encrypted and says how many accounts and applications there are.

send returns a BackupRef or raises. The id is yours — a path, an object key, a file id — and comes back to delete unchanged; label is what the Backups list shows. Raise with a sentence for the operator and Postulo shows it in your words, then retries with a growing wait (ten minutes, twenty, forty…) and, after six attempts, waits for an administrator to press Send.

list is what retention trusts. After a send is confirmed, Postulo keeps the newest N there and deletes the rest, but only references your list() returned and that Postulo itself sent. Anything else your destination holds is never deleted for you. A destination whose list does not return what it was sent never has an old archive removed.

Every request goes through client(), so the instance's destination policy applies, and the send runs inside a deadline that ends before the scheduler's claim on it does: an upload on a client that is not Postulo's is one nobody can stop. test is held to thirty seconds.

Settings are drawn from config_fields(), as a transport's are: secret=True fields encrypted at rest, never shown back, and kept when left blank. validate(config) is optional and runs when the form is submitted.

The per-person plugin policy does not apply, for the transport's reason: where the instance's archives go is the operator's plumbing, and nobody's account owns it.

Test yours against the contract. postulo.plugins.testing publishes assert_is_a_backup_destination(plugin, config, tmp_path=...): it sends a small archive, checks the reference, finds it in list() and deletes it. Point it at something your test suite controls.

Features

A feature is a capability of Postulo itself, switched on and off through the same policy and the same explanation as everything else here. It is the only kind that does not talk to anything outside the application. Like every plugin Postulo ships, a built-in feature is the administrator's to switch — for one person or for everybody, under Server settings — and not the person's: Settings → Plugins lists what was installed on the instance, and what Postulo ships only behind a check mark, with no switch on it. A feature of your own, installed from a wheel or a catalogue, is the person's to switch off for themselves, exactly as a notifier or a store is.

Postulo ships a good many, several of them grouped as modules (see Modules, sub-modules and what a plugin needs below). phone-numbers governs whether a person and their contacts may hold more than one telephone number; email-addresses governs whether the page for managing several email addresses is offered; postal-rules governs what a country expects of an address — what its parts are called, the order they are drawn in, and the little of it that is refused; employer-structure governs whether an employer may be more than one company. And social-profiles, repositories and websites govern one table between them, a kind each: whether a person and their contacts may hold more than one address on the web of that kind. Three switches rather than one, because whether to list every forge somebody publishes on and whether to show one LinkedIn and nothing else are different decisions, and one switch that answered both would answer neither. messaging-contacts governs whether a person and their contacts are offered messaging handles at all; off, there is no single box to fall back to, as there is for a telephone number, so nothing is offered and every row is kept.

A feature governs what Postulo offers and uses. It does not have to own the data. email-addresses owns none at all — the addresses belong to allauth, with allauth's flows reading them, and a plugin that cannot verify an address cannot honestly own one. Switching it off offers the primary address alone and stops offering the page; it deletes nothing, exactly as switching phone-numbers off leaves every number where it was.

A feature that could strand somebody needs a floor, and the floor goes below the policy. Hiding the addresses page from somebody who keeps a spare because their work address is about to stop working takes away their way back in on the day they need it — so it is never hidden from an account that already has more than one address, whatever the policy says. If switching your feature off could cost somebody something they cannot get back, that is the shape to copy.

[project.entry-points."postulo.features"]
my-feature = "my_package:MyFeature"
from postulo.plugins.api import Manifest, declares


@declares(Manifest(name="my-feature", label="My feature", kind="feature", version="1.0"))
class MyFeature:
    """A declaration. There is nothing to implement."""

A feature has no methods, and that is deliberate. It answers one question — is this on for this person — and the application asks policy.decide(name, person).on before offering the part of itself the feature covers. The moment a feature could act, "off" would mean two different things depending on which plugin you asked.

Off never deletes anything. This is the promise the whole plugin system makes, stated on two pages of the interface in every language Postulo speaks, and the kind whose subject is Postulo's own tables is not the exception to it. What a feature governs is what Postulo offers and uses. The rows stay where they are, the person is told how many are being kept back, an export carries all of them regardless, and switching it on again finds them unchanged. A feature that deletes on the way out is not a feature, it is a migration with a checkbox in front of it.

Modules, sub-modules and what a plugin needs

What ships inside Postulo is a module; what you install is a plugin. They share the same machinery, and a module may have sub-modules (#873): a module with no behaviour of its own whose switch is its children's master. Postulo ships these:

Module Its sub-modules
Multiple fields Multiple email addresses, telephone numbers, code repositories, social profiles, websites and messaging contacts
Built-in notifications Browser notifications, Email notifications and Webhooks
Extend file formats PDF (always on), DOCX, ODT, iCalendar, vCard and Europass import

Switching a module off switches its sub-modules off and takes their switches away, with the reason beside them; switching it on brings them all back, forgetting which were off, and each can then be switched off on its own. The internal names (email-addresses, webhook, …) never change, because policy rows, the archive and the plugins record key on them.

@declares(
    Manifest(
        name="acme-reports",
        label="ACME reports",
        kind="feature",
        parent="acme",                    # a sub-module of the module called acme
        categories=("calendar", "files"),  # display labels; kinds stay what the registry loads by
        requires=("maps", "helloworld>=0.3"),
    )
)
  • parent names the module a plugin is a sub-module of. There are two levels: a parent has no parent of its own, and a plugin cannot be its own parent.
  • categories are slugs from postulo.plugins.categories; a slug Postulo does not know is shown as it is. They group and filter what the page draws and change nothing about how the plugin runs.
  • icon (#874) names a Lucide icon drawn where a logo would go. It is for what ships inside Postulo; a package that has a logo of its own ships the file and names it in logo, which goes through the checks files go through, and wins when both are given.
  • required=True (#877) marks what cannot be switched off. PDF is the one that ships: its switch is drawn on and disabled, and neither a policy row nor its parent can turn it off.

What a plugin needs (#884)

requires lists the plugins and modules a plugin cannot work without, each with an optional version specifier, in the form of a requirement: "maps", "helloworld>=0.3". A module's version is Postulo's.

  • At run time, a plugin whose requirement is missing, or is switched off for the person, is off itself and its switch is not anybody's to move, with the names beside it.
  • At install time, a catalogue release may carry the same list as requires. A release that needs something absent is refused before anything is downloaded, naming what is missing, and Server settings → Plugins offers the missing plugin when the same catalogue lists it. Nothing is installed on an administrator's behalf: installing runs code, so each plugin is a decision of its own.
  • At removal, a package other installed packages need cannot be removed, and the refusal names them.

Two plugins that need each other do not loop: the second to be asked is told yes, and a test holds everything that ships to no cycle at all.

Settings, access and packages (#883, #885, #886)

  • settings on a manifest names the page (a URL name, as reverse takes it) where the plugin's settings are changed. Server settings → Plugins then draws a Settings button under its switch: a link for an administrator who may change them, and a button that is aria-disabled and says You are not authorised to change settings. for one who may not (a disabled button shows no tooltip on focus). A plugin with no settings has no button.
  • Access replaces the checkbox Each person may change it. Each plugin and module has an Access page, kept by administrators only: Everybody may switch it for themselves is the instance's own row (available against forced on), and rules for groups and accounts say who may use it, who may switch it for themselves (never for what ships inside Postulo) and who may change its settings: the page behind the Settings button refuses an administrator the rule excludes, not only the button. An administrator who is not the operator cannot write or lift a rule about their own account or one of their groups, nor change a group a rule names. An account's rule counts over its groups', and with several groups the most permissive reading wins. A rule narrows the default; it does not turn on what the instance switched off, and it does not override a row an administrator wrote for that person. Groups are Django's own, made and filled under People → Groups, and have no powers except to be named in a rule.
  • A package is the plugin and its entry points are its sub-plugins. A package that registers several plugins is drawn with its own row and its sub-plugins under it; one with a single entry point is one row. The package's switch is the one in Installed on this instance: off stops it loading, which takes its sub-plugins with it. A catalogue listing may carry categories = ["files"] in plugins.toml, shown as badges and filterable above the list of what a catalogue offers.

Formats that are switched off

A format module decides whether the format is offered: the Documents download menu, the Import and Export buttons, the downloads and the feeds. The addresses of a download or a feed answer 404 while their module is off, and answer again, at the same address, when it is on. The files you hold and the data they were made from are never touched. A Word or OpenDocument file you upload is kept exactly as it came, and its text is read once so that a search can find a word inside it; with the module off that text is not read. It is not a CV import — a document typed elsewhere has no structure Postulo can read reliably, so structured import stays with Europass and the candidate file.

Getting a plugin into an instance

From the interface. Server settings → Plugins installs a wheel an administrator uploads, or a plugin named in a configured catalogue. Plugins land in POSTULO_PLUGINS_DIR — on the data volume, not in the environment — which is added to the import path at startup, with a plugins.json beside them recording what is installed and where it came from. Because the record is on the volume, an upgrade cannot lose them: the container's entry point runs manage.py plugins sync at boot and reinstalls what the record lists and the new environment lacks.

The record is written beside itself and moved into place, and every change to it and to the directory holds a lock. A record that cannot be parsed refuses writes; a process keeps what it last read from it, and one that has read nothing counts every plugin as switched off, so a damaged record never turns a disabled plugin back on.

A plugin that is switched off does not run. Switched off for a person or for everybody, it is not only left out of the pages: notifiers do not deliver, stores do not receive copies, syncs do not run (Sync now included), sources are not read, and a connection to it cannot be created. Deliveries and copies already queued wait without spending attempts and resume when it is switched on again; nothing is deleted. A person's Settings → Plugins page shows what applies to them, which is the instance's decision where they have none of their own.

Installing a new version needs a restart. Every process keeps the code it has already loaded, so after an upgrade or a rollback each worker and the scheduler runs the old code until it is restarted. Only a first install is picked up without one.

Each step has a budget, held under the worker timeout of the image: fifteen seconds for the download as a whole, sixty-five for the installer and twenty for the import check. An install that is killed part way puts the directory and the snapshot back.

Three things are refused before anything is written, each with the reason: a wheel that is not py3-none-any (the image has no compiler), a package with no postulo.* entry point (installing it would do nothing), and a dependency that would move something already pinned here — Postulo's own environment, and everything another installed plugin brought with it. A conflict names the plugin whose pin it is about, so the answer is not "some package disagrees" but "postulo-apprise is using that version". Postulo's own pins come only from the packages outside the plugins directory, so a copy of one that a plugin brought along never takes its place.

A plugin's own dependencies are not held against it, so upgrading it may move them: it brought them, and nothing else is using them. The record holds a plugin's whole dependency closure, not only what the last install changed.

An install that can be undone

Installing writes over what is there, which for an upgrade means writing over the version that works. So an install is three steps rather than one:

  1. The directory is kept as it is. A copy goes in .previous inside the plugins directory — the whole directory, because a plugin's dependencies sit beside it and an upgrade moves those too.
  2. The new plugin is installed, and then imported, in a separate process, exactly as the registry would import it. A module is allowed to do anything when imported, including exit the process; doing that in a worker would take the worker with it, and there is no unimporting what a half-loaded module already did.
  3. Only then is it recorded. If it cannot be imported, the directory goes back to step 1 and the install is refused, quoting what the import said. A failed attempt costs nothing: it also gives back the .previous it replaced, so it does not spend the way back to the version before the one you are running.

For the upgrade that installs cleanly and turns out to be wrong — which nothing at install time could have caught, because nothing about a wheel says it — there is one step back:

manage.py plugins rollback

It puts the directory and the record back as they were before the last install, and says what moved. A plugin switched off since the snapshot stays switched off; the files and versions go back, the disabled flags do not. There is one snapshot, not a chain, and it is spent once used: undoing what you just did is what this is for. Rolling back the first install of all leaves no plugin, which is the state there was.

What a removal takes with it

A plugin's own files, and the dependencies it brought that nothing else is using — counted from what each install recorded as having arrived with it. A package two plugins both brought stays while either remains, and a package Postulo itself provides is not in the plugins directory at all, so it is never a candidate. Compiled copies (__pycache__) go too: they are in no RECORD, and every worker that imported the plugin wrote one.

What is verified, and where that stops. A catalogue's index carries an Ed25519 signature checked against the configured key, and each release names a SHA-256 that the downloaded wheel must match. That covers the plugin's own file. It does not cover its requirements: those are resolved from PyPI when the plugin is installed, and are whatever is served that day, along with whatever they need in turn. Trusting a plugin therefore means trusting its dependency list, and it is worth reading before you install.

Two things narrow it:

  • Only built wheels are installed (--only-binary :all:). A source distribution runs its own build code during installation, as the container's user, before anybody has seen it. A plugin that genuinely needs a source build is one to install by hand, deliberately.
  • Everything that arrived is recorded in plugins.json and listed under the plugin on Server settings → Plugins, so an administrator can see what is actually in the instance without a shell — including packages no wheel's metadata mentions, because a requirement of a requirement never appears there.

A catalogue carrying the whole resolved set with hashes, installed with --require-hashes, is the real answer and a change to the catalogue format; it is not built yet.

Install and Reinstall use the catalogue and version of the row that was pressed, and a release that catalogue no longer lists is refused; another catalogue is never asked. On the command line a name that several catalogues list is refused unless --catalogue says which.

From the command line, which is the same code:

manage.py plugins list
manage.py plugins install ./postulo_apprise-0.1.0-py3-none-any.whl
manage.py plugins install postulo-apprise      # by name, from a catalogue
manage.py plugins install postulo-apprise --catalogue custom  # when several list the name
manage.py plugins disable postulo-apprise      # stops it loading; the files stay
manage.py plugins remove postulo-apprise       # and what it brought, if nothing else uses it
manage.py plugins rollback                     # undo the last install
manage.py plugins sync                         # what the entry point runs at boot

Where a plugin says it came from

Server settings → Plugins labels every plugin with its provenance, and the label describes evidence rather than intent:

Label What it means Removable
Module Ships inside Postulo No — switched off for people through the policy rows
Official Its file matches what the official repository signed Yes
Custom Its file matches what a repository on this instance signed Yes
Uploaded Nothing here signed this file Yes

An upload can be official, and a name cannot make it so. A zip carries no evidence of who published it — so what is checked is the file. A signed index publishes each release's SHA-256; a wheel whose bytes match one is the file that repository published, whatever route it took. One byte of difference and it is not. The Plugins pages fetch no catalogue to draw themselves: they compare against the indexes the last Check for updates verified, and until one has run, or where no repository could be reached, the answer is Uploaded, never a guess.

Which repository is official is a key, not a name. Anybody can call a repository postulo; nobody else can sign with Postulo's key. Postulo publishes no catalogue yet, so nothing is official on any instance today.

None of this means safe. Installing a plugin runs somebody else's code inside Postulo, and that is as true of an official plugin as of any other. It also says nothing about dependencies: the signature and the checksum cover the plugin's own wheel, and its requirements are resolved from PyPI at install time and are whatever was served that day.

Publishing to a catalogue

A catalogue is one JSON file listing plugins, and beside it a detached Ed25519 signature over exactly those bytes. An administrator configures it as POSTULO_PLUGIN_CATALOGUES=name|url|public-key; without the key there is no catalogue, because an unsigned list of URLs to run code from is not something Postulo will offer. The index is fetched when somebody presses Check for updates, never on its own.

{
  "plugins": [
    {
      "name": "postulo-apprise",
      "summary": "Notifications through Apprise",
      "maintainer": "Postulo contributors",
      "licence": "AGPL-3.0-or-later",
      "repository": "https://source.tiagoagueda.com/postulo/postulo-apprise",
      "releases": [
        {
          "version": "0.1.0",
          "url": "https://…/postulo_apprise-0.1.0-py3-none-any.whl",
          "sha256": "…",
          "requires_postulo": ">=0.2",
          "provides": ["postulo.notifiers:apprise"]
        }
      ]
    }
  ]
}

Newest release first. Every wheel is checked against the SHA-256 the signed index carries, so a mirror or a hijacked download host cannot ship code. requires_postulo is a version specifier and it is enforced: a release that names the Postulo it is for is refused, before the download, on a Postulo it is not for, and the refusal names both versions. Leave it out if you do not know; an installed plugin that stops fitting after a core upgrade is marked for Postulo … on the plugins page rather than only failing at import. The plugins the project publishes declare >=0.3,<1.0 -- the major, so a 0.3 plugin installs on core 0.4 -- and carry the version of the core they were released beside, so a person can pair the two at a glance. Being listed means the people who publish that catalogue looked at the plugin — its contract, its licence, that it does nothing with the network or with secrets beyond what it says. That is a review, not a guarantee, and the page says so.

Getting a plugin into the container image

The image installs a locked environment at build time and runs as a user that cannot write to it. Installing through the interface handles that by putting plugins on the volume; to bake one into the image instead:

# 1. A build argument, kept in a Compose file of your own (docker/compose.plugins.yml, not
#    tracked) so that every rebuild carries it: any number of packages, in the form pip accepts.
services:
  postulo: &plugins
    build:
      args:
        POSTULO_EXTRA_PACKAGES: git+https://source.tiagoagueda.com/postulo/postulo-apprise.git
  scheduler: *plugins
  worker: *plugins
docker compose -f docker/compose.yml -f docker/compose.build.yml -f docker/compose.plugins.yml up -d --build

Upgrade such an instance by rebuilding with the same three files, never with docker compose pull; Installing Postulo has the whole recipe.

# 2. Your own image on top of Postulo's.
FROM source.tiagoagueda.com/postulo/postulo:0.3
USER root
RUN apt-get update && apt-get install -y --no-install-recommends git \
    && uv pip install --no-cache --constraint /app/constraints.txt \
        git+https://source.tiagoagueda.com/postulo/postulo-apprise.git \
    && rm -rf /var/lib/apt/lists/*
USER postulo

Both install into Postulo's environment with the core's lock as a constraint, so a plugin cannot change the version of a package Postulo pins. Installing from the interface — an uploaded package, a catalogue — is described under From the interface.

Adding a settings section

The Settings area is a sidebar of sections, each its own page. A plugin with per-person settings registers a section and it appears beside the built-in ones, in the order it asks for. Register it when your app is ready:

from django.utils.translation import gettext_lazy as _

from postulo.core.settings_sections import SettingsSection, register

register(
    SettingsSection(
        slug="myboard",
        label=_("MyBoard"),
        url_name="myboard:settings",  # your own view, rendered with settings/base.html
        icon="link",  # any icon in assets/icons.txt
        order=60,  # built-in sections use 10 to 50
    )
)

Your template extends settings/base.html and fills content; the sidebar comes with it. Give match the URL names of any further pages that belong to your section, so it stays highlighted while a person is on them.

Draw the page with Postulo's own vocabulary and it will look like the rest of the settings. A form field is a component: <c-field :field="form.api_key" /> renders the label, the widget, the help text and the errors exactly as every core form does, with the ids a screen reader needs already in place. A button is Basecoat's: <button class="btn"> is the filled one, and data-variant="outline", "ghost", "destructive" or "destructive-ghost" with data-size="sm", "xs" or "icon" are the others. A card is class="card", an inline notice class="alert-info" (or -success, -warning, -error). Since 0.4.0.

A worked example

The complete, installable version of everything on this page is postulo-helloworld: a capture source and a notifier, translations of its own, tests that run both through Postulo's registry, and a CI workflow — under the MIT licence so you can copy it into your own plugin without a second thought. Start there. The fragment below is its source, cut down to the shape.

import json
from postulo.plugins.api import JobPostingData


class MyBoardSource:
    name = "myboard"
    version = "1.0"

    def can_handle(self, url: str) -> bool:
        return "myboard.example/jobs/" in url

    def parse(self, url: str, html: str) -> JobPostingData | None:
        # This board hides a tidy JSON blob in its page, which is far more stable than
        # its markup.
        marker = "window.__JOB__ = "
        start = html.find(marker)
        if start == -1:
            return None

        try:
            payload = json.loads(html[start + len(marker) :].split("</script>", 1)[0].strip(" ;"))
        except ValueError:
            return None

        return JobPostingData(
            title=payload.get("jobTitle", ""),
            company_name=payload.get("employer", {}).get("name", ""),
            location=payload.get("city", ""),
            description=payload.get("descriptionText", ""),
            url=url,
            source="myboard.example",
        )

If your source is useful to more than you, consider publishing it. Postulo does not need to know it exists for anyone to install it.