Table of Contents
- Writing a plugin
- Writing a capture source
- What a source is
- The data you return
- Registering it
- Translations: every plugin holds its own
- How sources are chosen
- The built-in sources
- Testing yours
- Fetching, and what your source does not have to think about
- Plugins that connect to another service
- What you may import from Postulo
- The plugins Postulo ships are packages like yours
- A logo, if you have one
- A theme, if you set documents
- Saying who you are
- Identifier registries
- Importers
- Transports
- Features
- Getting a plugin into an instance
- Where a plugin says it came from
- Publishing to a catalogue
- Getting a plugin into the container image
- Adding a settings section
- A worked example
Writing a plugin
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 each document 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 |
Capture sources were the first kind, and much of this page is about them. The kinds that talk to another service share the connection machinery under Plugins that connect to another service.
Writing a capture source
Postulo reads job postings through sources. Two are built in, and anyone can add more by installing a Python package — no fork, no patch to this project, and no waiting for it to be accepted.
This exists because the person who cares about a particular job board is almost never the person maintaining Postulo. If a board matters to you, you should be able to teach one instance about it in an afternoon.
What a source is
Anything with four names. There is no base class to inherit, on purpose: a plugin should not have to import Postulo internals, or track their changes, just to be recognised.
class MyBoardSource:
name = "myboard" # recorded against every capture this source produced
version = "1.0" # so a capture can be traced to the code that made it
def can_handle(self, url: str) -> bool:
"""Whether this source wants to parse the URL."""
return "myboard.example" in url
def parse(self, url: str, html: str) -> JobPostingData | None:
"""Extract a posting, or return None if the page yielded nothing useful."""
raise NotImplementedError
Sources are given a URL and the HTML fetched from it, and return data. That is all they do. A source does not touch the database, decide whether its own result is good enough, or create anything — the person capturing does that on the review screen.
That boundary is the whole design. A parser reading markup it has never seen gets things wrong, and when it does, the cost should be a few seconds of somebody's attention rather than a fabricated job title in their records.
The data you return
postulo.plugins.api.JobPostingData is a Pydantic model, and it is the only shape
Postulo accepts. The built-in parsers, your plugin and the capture API all validate
through it, which is what stops a source inventing a field or misspelling one.
| Field | Type | Notes |
|---|---|---|
title |
str |
The only required field |
company_name |
str |
|
location |
str |
Free text, as it should read on screen |
remote_type |
str |
onsite, hybrid or remote |
employment_type |
str |
full_time, part_time, contract, freelance, internship, apprenticeship |
description |
str |
Plain text. Truncated past 40 000 characters rather than rejected |
salary_min / salary_max |
Decimal | None |
|
salary_currency |
str |
Three letters |
salary_period |
str |
year, month, day or hour |
posted_at / closes_at |
date | None |
|
url |
str |
|
source |
str |
Where it came from, usually the hostname |
Unknown fields are rejected outright. If you need one Postulo does not have, open an issue — an extra column that only one plugin understands helps nobody.
Leave a field empty rather than guessing at it. A blank box on the review screen is an invitation to type; a confidently wrong value is something a person has to notice before they can correct it, and they will not always notice.
Registering it
Advertise an entry point in the group postulo.sources:
# pyproject.toml of your plugin package
[project.entry-points."postulo.sources"]
myboard = "my_package.source:MyBoardSource"
Install the package into the same environment as Postulo:
uv pip install my-postulo-myboard
That is the whole installation. Restart Postulo and the source appears on the capture page. Uninstalling the package removes it.
Translations: every plugin holds its own
Postulo speaks many languages, and a plugin must speak them itself. Its labels, help texts and messages are never added to Postulo's catalogues: a plugin author adds a language without waiting for a Postulo release, and a plugin translated into a language Postulo does not yet have still shows it. The rule is one directory:
my_package/
__init__.py
source.py
locale/
fr_FR/LC_MESSAGES/django.po
fr_FR/LC_MESSAGES/django.mo
pt_PT/LC_MESSAGES/django.po
pt_PT/LC_MESSAGES/django.mo
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
It reads pyproject.toml for the project's name and expects the package under src/,
which is the layout above. extract creates the same sixty-eight slots Postulo has, so a
release sweep has somewhere to write; the rule is the same on both sides of the plugin
boundary — English is the source, French and European Portuguese while working, the full
set at a release, held by uv run pytest -m release in the plugin's own suite, because
Postulo's gate cannot see a plugin's catalogues (#187). django-admin makemessages still
works for a plugin that would rather not depend on Postulo to develop.
Ship the compiled .mo files in the package. 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.
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 takes the title the page declares and its
readable text, and lets the person capturing fix the rest. Deliberately unambitious.
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, revalidated on each redirect;
- the site's
robots.txtis honoured; - one page, ten seconds, 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. A field marked secret is stored encrypted and is never rendered
back; config as passed to your methods holds configuration and secrets together.
If what you ask for depends on the person, add a user=None keyword to
config_fields or to test, and Postulo hands you the account the connection belongs to.
Most plugins need nothing of the kind — a server address is the same whoever types it —
and a plugin without the keyword is called without it. The built-in email notifier is the
example: its one field is a choice among the person's own verified addresses, because a
notifier sends to the person and not to anybody they name, and test checks the same
thing before it sends. Test is bounded per account (POSTULO_CONNECTION_TEST_RATE), so
a plugin's test need not bound itself.
Register it in the group for its kind — postulo.notifiers, postulo.stores,
postulo.syncs:
[project.entry-points."postulo.notifiers"]
mynotifier = "my_package:MyNotifier"
Connected plugins hold their own translations too — the locale/ directory described
above applies to every kind of plugin.
When the other side has ended the connection, raise ConnectionUnusable instead of an
ordinary exception:
from postulo.plugins.api import ConnectionUnusable
raise ConnectionUnusable(_("The provider revoked this token. Connect it again."))
An ordinary failure is recorded and tried again next time, which is right for a server that
is down. This is the other kind — a revoked token, a withdrawn push subscription, an account
that no longer exists — where every later attempt fails identically. Postulo switches the
connection off, shows your message to the person, and forgets the stored secrets unless you
pass keep_secrets=True. Nothing is deleted: switching it back on is one press once they
have fixed whatever it was.
Not speaking HTTP? A plugin that opens its own socket — a mailbox, a message queue — asks for the address first, and keeps the name for TLS:
from postulo.plugins.api import DestinationRefused, approve_host
try:
address = approve_host(config["host"]) # resolved, held to the instance's policy
except DestinationRefused as error:
return TestResult(False, str(error)) # the message is for the person
client = SomeClient(address, port=config["port"], server_hostname=config["host"])
approve_host resolves the name, refuses it unless every address it answers with is allowed
here (POSTULO_CONNECTIONS_ALLOW_PRIVATE decides what "allowed" means), and hands back the one
to dial — so the address you connect to is the address that was approved, and no second lookup
can answer differently. Prove the certificate against the name, never the address.
check_destination(url) answers the same question for a URL without connecting.
Use postulo.plugins.http.client() for every request. It carries Postulo's timeouts and
user agent, and it enforces the instance's destination policy on every request, redirects
included: private and local addresses are refused unless the operator set
POSTULO_CONNECTIONS_ALLOW_PRIVATE=true, which is where self-hosted services usually live.
A plugin that opens its own connection bypasses that policy, and a reviewer will say so.
Two more methods are optional, and Postulo looks for them by name:
def validate(self, config) -> dict[str, list[str]]:
"""Runs when the form is submitted, with configuration and secrets together.
Return problems keyed by field name; an empty key is a problem with the form
as a whole. An empty dict means the configuration is fine.
"""
if "://" not in config["url"]:
return {"url": ["That is not an address."]}
return {}
def summary(self, config) -> str:
"""One line for the connections list. Mask every secret part."""
return f"{config['url']} as {config['token'][:2]}…"
validate is where a plugin that can tell a typo from a token says so — at the form,
rather than at three in the morning when a reminder falls due. summary exists because
secrets are never shown back: it is how a person tells two connections to the same plugin
apart. A secret may be a textarea when it is naturally several lines (Apprise takes a
list of URLs with the credentials inside); it is stored, masked and kept-when-blank like
any other secret.
A third 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.
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.
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 is every one of them: reminder_due,
capture_received, went_quiet), a title, an optional body and an optional url.
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 Postulo code (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 |
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.
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, 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. 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 —
the remote address, the identifier the remote uses, the version tag it last gave, a hash
of what was last pushed — kept beside the record, never on it. SyncLink.for_record,
of_model and bind are the whole API. 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.
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:
| 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 |
reschedule_interview, settle_interview |
Moving a meeting, and saying how it went |
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, phone_number_is_taken |
Writing one back, with the primary flag and the uniqueness rules left to Postulo |
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 |
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. 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.
What you may import from Postulo
One module, and it is a promise:
from postulo.plugins.api import FieldSpec, TestResult, declares, shipped
And one way to prove you kept it. postulo.plugins.testing is the check Postulo runs
over the plugins it ships, published so that yours can run it over itself (#229). It reads
your source rather than importing it, so a lazy from postulo.core import site inside a
method fails it too:
from pathlib import Path
from postulo.plugins.testing import assert_imports_only_the_surface
def test_the_plugin_imports_only_the_surface():
assert_imports_only_the_surface(
Path(__file__).parent.parent / "src" / "postulo_yours", package="postulo_yours"
)
Pass allowed={"postulo.something": "the reason"} for a dependency you have decided on
deliberately — and it fails on an entry you left behind after removing the import, because
a stale one hides the next real dependency.
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 |
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, 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 |
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 |
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 |
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 — which are the shapes a theme sets |
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 |
Keeping two sides the same
| Name | What it is |
|---|---|
SyncLink |
One local record's twin on the other side: the remote address, the identifier, the version tag, a hash of what was last pushed |
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 |
reschedule_interview, settle_interview |
Moving a meeting, and saying how it went |
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, phone_number_is_taken |
Writing one back, leaving the primary flag and the uniqueness rules to Postulo |
event_lines, calendar_status |
RFC 5545 for one interview, written the way Postulo's own feed writes it |
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
Fourteen of them across eight kinds, and they live where yours would:
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 expects of an address, and what it calls each part
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
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 5 MB in, 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", 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 or a letter: a template per kind, and the CSS inlined in it. Declare one and Postulo offers it in the picker beside its own.
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; nothing is copied and nothing is compiled.
vellum/
__init__.py
locale/
templates/
vellum/
letter.html
templates is the declaration. A theme sets what it has a template for, and nothing
else. If yours sets letters but not CVs, say so by having only the one file: the CV picker
will not offer it, and asking for the pair directly raises CannotRender with a sentence in
it rather than a missing-template traceback. A theme that answered for a kind it had never
seen by falling back to Postulo's plain would put somebody's Vellum letter beside a plain
CV in one envelope, which is the same bad outcome said too late to do anything about.
Your template owes the reader a language and a direction. Postulo hands you
document_language and document_direction; a template that ignores them renders an Arabic
CV left to right. The simplest way to get this right is to extend Postulo's own base:
{% extends "documents/themes/base_letter.html" %}
{% block page_styles %}…{% endblock %}
which is a template, not a Python import, and is what Postulo's own two themes do.
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.
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="https://example.org/your-plugin/logo.png",
)
)
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 to show beside your plugin's name. Nothing renders it yet.
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
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. There are six of them, and a test walks every
one 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 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.
Importers
An importer reads a person's career out of a file they upload — the mirror of a source, for a different input and a different output. Europass ships as one, and Europass is not the last CV format: JSON Resume, HR-XML, a LinkedIn export, a PDF somebody wants parsed. Each is somebody's own itch, and the person who cares about one should not have to wait for this project to accept a patch about it. So the group is open (#105).
[project.entry-points."postulo.importers"]
json-resume = "postulo_jsonresume:JsonResumeImporter"
from postulo.plugins.api import ImportRefused, Manifest, Record, declares
@declares(Manifest(name="json-resume", label="JSON Resume", kind="importer", ...))
class JsonResumeImporter:
def can_handle(self, data: bytes, filename: str = "") -> bool:
head = data[:4096].lstrip(b"\xef\xbb\xbf").lstrip()
return head.startswith(b"{") and b'"basics"' in data[:4096]
def read(self, data: bytes) -> Record:
try:
document = json.loads(data)
except ValueError as error:
raise ImportRefused(_("That is not readable JSON: %(error)s") % {"error": error})
record = Record(source="json-resume")
for job in document.get("work", []):
record.experience.append({...})
return record
The identity fields are the ones every plugin declares — see Saying who you are — and
kind is "importer". The two methods are the contract:
can_handle(data, filename="") -> bool answers what is this, not is it any good. It
is handed the whole file and the name it arrived under, and it should answer from the
shape of the bytes rather than from the name: an export arrives called all sorts of things,
and somebody who renamed it has not thereby changed what it is. A truncated export is still
an export, and saying "not readable JSON" from read helps far more than "nothing here
reads that" from here. Importers are asked in order — yours before the built-in one, as
with sources — and the first to say yes is the one that reads.
read(data) -> Record turns the bytes into a Record, which is on the surface. A
record is a career in Postulo's terms rather than the file's, so that every importer fills
the same one and the review screen and the writer need to know about none of them. Its
lists hold plain dictionaries; these are the keys the writer reads, and a missing key is an
error rather than a blank:
| List | Keys |
|---|---|
experience |
role, organisation, location, start_date, end_date, summary — an entry with no start_date is skipped, and skipped says so |
education |
qualification, institution, location, start_date, end_date, grade, highlights |
languages |
name, proficiency — a CEFR level in lower case, a1 to c2, or native, or "" for a level the file does not state; never guess one |
skill_groups |
name, and skills, a list of names |
projects |
name, and optionally summary |
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.
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 and is
reported as one.
Two rules matter more here than anywhere else in this document.
Postulo refuses the file before you see it. refuse_unreadable rejects an empty upload,
anything over MAX_IMPORT_BYTES, and a DOCTYPE in anything that looks like XML — which is
where entity expansion lives. That belongs to the kind rather than to each importer, because
an importer is handed a file a stranger's browser uploaded, and "every plugin author
remembers" is not a control. It runs before can_handle is asked, so your importer never
sees a file that failed it. If you parse XML yourself, still use a parser that resolves no
entities: the check refuses a declared DOCTYPE, not every trick a parser has.
An importer writes nothing. It turns bytes into a record and stops. What reaches the database is decided on the review screen, by the person, which is the same rule a source obeys and matters more here: a source that guesses wrong costs somebody a few seconds, and an import writes a career. It is also what keeps an importer from needing ownership scoping of its own: it never sees whose file it is.
Testing yours. read takes bytes and returns a dataclass, so most of a test suite needs
no Django at all. For the page, register the importer for one test with
registry.register_builtin("importer", MyImporter) and post a file to
resume:europass_import — the page asks the registry rather than naming Europass, and the
record your importer returned is what the review screen shows. tests/test_importers.py in
Postulo's own repository does exactly this with a test double.
Transports
A transport carries a message off this machine. Postulo ships one, SMTP, and names no vendor.
The reason the kind exists: a self-hoster whose provider blocks outbound 25, 465 and 587 — which is most residential connections and several hosts — has no route today except finding a relay that speaks SMTP. A transport lets them install one that speaks an HTTP API instead.
[project.entry-points."postulo.transports"]
carrier = "postulo_carrier:CarrierTransport"
class CarrierTransport:
name = "carrier"
version = "1.0"
kind = "transport"
label = "Carrier"
description = _("Delivers over Carrier's HTTP API.")
def config_fields(self) -> list[FieldSpec]: ...
def test(self, config: dict) -> TestResult: ...
def deliver(self, messages: list, config: dict) -> int: ...
deliver takes Django EmailMessage objects and returns how many went, which is the
contract an email backend already has — so a transport can be a thin wrapper around one where
that is the honest implementation. Raising is a failure, and the caller reports it.
A transport is not a notifier, and the difference is worth keeping. A notifier decides that something is worth telling somebody and writes the words; a transport gets those words to them. One sits on the other. Merging them would make "when should Postulo tell me things" and "how does this instance reach the outside world" the same form, and they are not: the first is a person's preference, the second is the operator's plumbing.
Three things follow from that.
Installing yours does not redirect the mail. The registry prefers third-party plugins for sources — a plugin written for one job board knows more about it than a general parser does — and that argument does not transfer to where an instance's mail goes. An administrator chooses, on Server settings → Email, and until they do the built-in one carries it.
Your settings are drawn from config_fields(), on that page, exactly as a connection's
are, with secret=True fields encrypted at rest and never shown back. SMTP is the one
exception: its settings are named columns that predate the kind, each overridden individually
by its own environment variable, and a fresh instance has to be able to send a verification
email before there is a row in the database to read.
The per-person plugin policy does not apply. None of available, unavailable, forced on or forced off means anything about mail delivery, and forced off would be an account nobody can recover. Postulo refuses all four for a transport rather than merely leaving them off the page.
The lock. A transport may not be switched off — nor its package removed — while it is the
last way anybody could get back into their account. That is evaluated, not hardcoded to a
name: recovery_routes() lists the ways in that exist, and while removing yours would empty
that list, the refusal stands and says whose accounts it is protecting. Add another route and
the lock opens by itself.
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 seven. 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;
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.
A feature governs what Postulo offers and uses. It does not have to own the data.
email-addresses owns none at all — the addresses belong to allauth, with allauth's flows
reading them, and a plugin that cannot verify an address cannot honestly own one. Switching
it off offers the primary address alone and stops offering the page; it deletes nothing,
exactly as switching phone-numbers off leaves every number where it was.
A feature that could strand somebody needs a floor, and the floor goes below the policy. Hiding the addresses page from somebody who keeps a spare because their work address is about to stop working takes away their way back in on the day they need it — so it is never hidden from an account that already has more than one address, whatever the policy says. If switching your feature off could cost somebody something they cannot get back, that is the shape to copy.
[project.entry-points."postulo.features"]
my-feature = "my_package:MyFeature"
from postulo.plugins.api import Manifest, declares
@declares(Manifest(name="my-feature", label="My feature", kind="feature", version="1.0"))
class MyFeature:
"""A declaration. There is nothing to implement."""
A feature has no methods, and that is deliberate. It answers one question — is this on
for this person — and the application asks policy.decide(name, person).on before offering
the part of itself the feature covers. The moment a feature could act, "off" would mean two
different things depending on which plugin you asked.
Off never deletes anything. This is the promise the whole plugin system makes, stated on two pages of the interface in every language Postulo speaks, and the kind whose subject is Postulo's own tables is not the exception to it. What a feature governs is what Postulo offers and uses. The rows stay where they are, the person is told how many are being kept back, an export carries all of them regardless, and switching it on again finds them unchanged. A feature that deletes on the way out is not a feature, it is a migration with a checkbox in front of it.
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.
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".
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.
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:
manage.py plugins rollback
It puts the directory and the record back as they were before the last install, and says what moved. 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.
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 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 |
|---|---|---|
| Internal | 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. If no repository can be reached at the moment the question is asked, 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": "Tiago Agueda",
"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: any number of packages, in the form pip accepts.
docker compose -f docker/compose.yml build \
--build-arg POSTULO_EXTRA_PACKAGES="git+https://source.tiagoagueda.com/postulo/postulo-apprise.git"
# 2. Your own image on top of Postulo's.
FROM source.tiagoagueda.com/postulo/postulo:0.2
USER root
RUN uv pip install --no-cache postulo-apprise
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 a later step on the roadmap.
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.
Postulo
Running it
- Installing Postulo
- Configuration
- Accounts and invitations
- Backups and your data
- Hardening
- Accessibility
- Health, metrics and logs
- Troubleshooting
Using it
- Getting started
- Listings
- Tracking applications
- Capturing postings
- Insights and the dashboard
- Reports
- Your career record
- CVs and portfolios
- Letters
- Files and what you sent
Building on it
Project