Writing a plugin¶
Esta página ainda não foi traduzida, por isso é mostrada em inglês.
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:
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:
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
httpandhttps; - 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.txtis 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:
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
discoverwith 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
validatesees the choice with the rest of the configuration. The field'srequiredis 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
discoveredfield 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, andlast_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 asubmittask and ahear-backtask, and an interview an event and a task. Slots are free text: one plugin's slots mean nothing to another;remote_goneandunpaired_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, orNone.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, orNone. 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 clearsunpaired_at, and it clearsremote_goneunless 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 raisesAlreadyPaired, whoselinkis the pairing that holds it.SyncLink.unbind(connection, record, slot=""): undo a pairing, leaving the record and the remote item alone. The row stays, withunpaired_atset andpairedfalse, and it returns the link, orNonewhere 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 bysearch(the words they typed) andkind(one ofRECORD_KINDS, or empty for any). Givenkey, 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 throughclient()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 theSyncLinka pairing writes.fill_record(connection, config, item, kind, into=None)makes a record ofkindfrom the item, or fillsinto— 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=oractor=), never by saving a model. What you return must be a record of that kind and the connection owner's own, andintoitself when you were given one; anything else is refused and nothing is paired. A person is never made:youis 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) andartefact=(one of their uploaded files; adocumententry needs one) are refused withNotBound, aValueError, 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 answerscreatedasFalse—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.
captureis 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:
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.
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:
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>, noon…handler, no<foreignObject>, no<image>, no reference out of the file (<use href>to a#fragmentonly,url(#…)only). Its<style>rules andstyle=attributes become presentation attributes on the elements they match, and itsids 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 ansrcor aurl(); - a WOFF2, WOFF, TrueType or OpenType font is written as an
@font-facerule with adata:source, so put it inside your<style>.familyis required (letters, digits, spaces,-and_);weightisnormal,bold,100…900or a range such as100 900;styleisnormal,italicoroblique.
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
labelmay be a lazy string that reads in the reader's language, so takestr()of it when it is drawn and not before; - such a scheme carries
quoted, andurl_forpercent-encodes the value into its link, andmax_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_foranswers with the key andurl_forwith 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
cleanuntouched (is_as_stored), and is checked when either changes. Itskept_as_it_wasis the sentences today's scheme says against it — empty for a row that fits — and itsurlis empty while there are any. An importer asksas_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.
Link services¶
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.
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.
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.
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).
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.
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.
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.
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"),
)
)
parentnames 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.categoriesare slugs frompostulo.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 inlogo, 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)¶
settingson a manifest names the page (a URL name, asreversetakes 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 isaria-disabledand says You are not authorised to change settings. for one who may not (adisabledbutton 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"]inplugins.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:
- The directory is kept as it is. A copy goes in
.previousinside the plugins directory — the whole directory, because a plugin's dependencies sit beside it and an upgrade moves those too. - 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.
- 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
.previousit 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:
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.jsonand 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.