• v0.4.0 d1095b2382

    Postulo 0.4.0
    All checks were successful
    CI / Unit tests (Python 3.13) (push) Successful in 14m46s
    CI / Unit tests (Python 3.12) (push) Successful in 16m10s
    Dev image / Dev image: build, scan, push (push) Successful in 17m51s
    CI / Database tests on PostgreSQL (push) Successful in 3m57s
    CI / Security audit (push) Successful in 1m23s
    CI / Stylesheet, icons and scripts current (push) Successful in 24s
    CI / Unit tests and coverage (Python 3.14) (push) Successful in 10m57s
    CI / Browser tests (Chromium) (push) Successful in 32m36s
    Release / Publish the release (push) Successful in 23s
    Stable

    tiagoagueda released this 2026-10-04 16:58:36 +00:00 | 693 commits to main since this release

    🔒 Security

    • Deleting an account or a company removes its export archives and logos from the disk, and prune_media no longer lists a live export as an orphan: the files are found by walking every model's file fields, and an archive's bytes go with its row however the row is deleted. (#355)
    • Server settings → Data protection no longer names other accounts' contacts or shows their connections' labels and configuration: staff see counts, per plugin and kind for the record of processing. (#369)
    • A number filter given a value like 1e1000000 is ignored instead of holding a server worker for half a minute. (#499)
    • A plugin switched off for a person, by an administrator or by themselves, no longer runs for them: notifiers, queued webhooks, stores, syncs, sources and importers ask the policy where they run, the create address of an unavailable one is a 404, and what was waiting resumes when the decision is reversed. (#362)
    • Your own email now starts on STARTTLS and refuses to sign in with a password or token when no encryption is chosen. (#360)
    • The operator's POSTULO_EMAIL_HOST exemption from the private-address rule no longer covers a person's own outbox: it dials public addresses only unless the operator allows private ones. (#358)
    • A career file's document type declaration is now found wherever it sits (after a long comment, or in UTF-16) and the Europass reader parses with defusedxml, so a declaration can no longer slip past the refusal and have its entities expanded (#378)
    • Appointing an administrator no longer makes them a superuser, and Django's admin lists no person's records. Administrators appointed before keep the flag; clear it with manage.py shell (see the wiki's Accounts and invitations). (#368)
    • The image carries /app/constraints.txt, so a plugin installed on top of it cannot move anything Postulo pins; the wiki's examples use it. (#542)
    • Wrong tokens presented to /logs and /metrics are counted per address and shut it out after ten an hour; a token shorter than 24 characters is warned about. (#472)
    • Behind nginx or Apache an SVG is still sent by Postulo, so its sandbox policy always reaches the browser; the wiki shows the headers the proxy must add for the rest. (#415)
    • The capture API's "known" answer no longer carries a held listing's company, address or state, and the scope's label says it can ask. (#536)
    • Restoring a backup refuses member names that a Windows path would read as leaving the media or plugins directory. (#478)
    • An imported interview with an unsafe calendar identifier or an unknown outcome can no longer add a line to a feed or break its file. (#450)
    • Server settings → People → Plugins answers the same to a stranger whether or not the account exists. (#373)
    • The weekly dependency audit also reads gunicorn and psycopg, which only the image installs. (#583)
    • @parcel/watcher 2.6.0, for GHSA-vfj7-8cjw-p6xm (a deeply nested pattern could exhaust the stack in braces), pinned by an npm override so that Tailwind stays at 4.3.3 and the stylesheet is unchanged; build-time only, nothing of it runs in the application. (#708)
    • An outbound request no longer writes its address to the log. The HTTP client recorded every URL at INFO, so staff and a log collector could read other people's capture, connection and webhook addresses. Logs already kept hold those lines until they rotate out; delete postulo.log* to be rid of them now. (#548)
    • An invitation opens one account: on PostgreSQL, sign-ups sent at the same moment through one invitation could each get one. (#544)
    • A new API token's secret is shown by the response that made it and stored nowhere: it used to wait in the session table, where a backup kept it if the next page was never drawn. Upgrading removes any left there. (#441)
    • Failed sign-ins are counted per account: its username and each of its addresses used to have five guesses apiece, and so did each host name the instance answers to. (#489)
    • Django's admin has no login of its own and sends you through Postulo's sign-in: its form opened the whole instance on a password alone, whatever second factor the account had. If you set POSTULO_ADMIN_URL, nothing to do: you are asked for your code on the way in. (#367)
    • urllib3 2.8.0, for CVE-2026-97689 (a hostile server could make a chunked response grow without bound in memory) and CVE-2026-97687 (an HTTPS proxy could take the target's TLS settings); and virtualenv 21.14.2, a development tool, for four advisories of its own. (#324)
    • PyJWT 2.15.1 and django-allauth 65.19.5, for CVE-2026-102274. oauthlib's CVE-2026-49265 is a timing leak on an OAuth2 provider's token endpoint, which Postulo never runs; allauth still requires oauthlib 3, so the audit ignores that one advisory by name until it can be taken, and a test fails the day it can. (#323)
    • The source a capture keeps is never served as a page: it is stored as text, shown escaped and downloaded as text/plain. Kept files are capped each and per account and refused before they are read, and an API request too large to read answers 413 as a problem document instead of an HTML 400. (#256)
    • The image takes Debian's security updates on every build the project makes; the layer that upgrades had been served from the builder's cache. Building your own: add --pull --build-arg POSTULO_APT_REFRESH="$(date -u +%FT%TZ)", or a cached build keeps its old packages. (#300)
    • Invitation tokens are stored as fingerprints and the link is shown once, on the page that
      made it; links already sent keep working. (#232)
    • Forwarding headers are believed only from this host unless you name your proxy.
      Upgrading behind a container or another machine: set POSTULO_TRUSTED_PROXIES to its
      network
      , which Server settings → Overview now shows. (#232)
    • The email notifier sends only to your own verified addresses, and every Test button
      is bounded by POSTULO_CONNECTION_TEST_RATE. (#232)
    • Every address that names a record is swept for another account's, on pages and the
      API alike, and the feeds, pictures, logos and export have boundary tests. (#232)
    • The content security policy applies in every settings module and the browser suite fails
      on a violation; CV and letter previews, which it had been refusing to style, carry a
      nonce. (#232)
    • A capture address sent to the API holding a line break or another control character is refused with a 422, and addresses are logged quoted, so one can no longer write a forged line into the console log. (#321)
    • The public-only address check judges the IPv4 address inside an IPv6 one (NAT64, 6to4, Teredo, mapped) and refuses site-local fec0::/10 and the rest of the reserved ::/8. On a host with NAT64, 64:ff9b::a9fe:a9fe reached the cloud metadata service. (#321)
    • A page, its robots.txt and a logo are read as they stream in and stop at their size limit, and the whole download, headers included, is over within 30, 10 and 20 seconds; only gzip and deflate are unpacked. A hostile site could make a worker hold as much as it liked, for as long as it liked. (#321)
    • import_data takes an archive's username only when it passes the username rules and is not reserved; otherwise the account keeps its own and the report says so. A username ending in a line break no longer passes the pattern. (#321)
    • Find logo reads an icon's sizes in time that follows its length: a company's website with an over-long one could hold a worker for hours. (#321)
    • Importing an archive builds every row from the fields an export writes, so a crafted file can no longer write into another account's records, profile or files. (#354)

    🔧 Changed

    • The Applications board is centred on the screen, its columns open and fold in motion (not where less motion is asked for), and several can be open at once: a strip opens beside the open columns and an open column's heading folds it alone. ?status= takes several, comma-separated, in the table too. (#709)
    • The interface says Type, not Kind, for what sort of thing something is; the design page's typography section is Typography, and the report's period selector is Period. (#705)
    • Copyright notices name the Postulo contributors as the holder, and CONTRIBUTING.md says on what terms a contribution comes in. (#346)
    • Your details keeps its help where the question is asked: each card has a question mark that shows one sentence and opens the card's help in a drawer, and what a field is for is a tooltip on hover and on focus, wherever there is room for one. With scripts off the help is on the page. (#302)
    • On the Applications board a column's heading folds the board to that column, the others becoming strips that keep their name and count; All columns undoes it. This replaces the board's status filter, and ?status= still says which. The board also scrolls sideways while a card is dragged near its edge. (#315)
    • A telephone number is checked against its country's numbering plan: an impossible one is refused, saying why, an unplaceable one kept with a warning, and all are grouped their country's way. API: a contact's national number needs + or the new phone_country, else 422, and comes back in international form. (#304)
    • A postal address answers to its country before it is kept: a postcode in a form it never uses, or a required part left empty, is refused beside it; postcode spacing is put right; a town and a country alone are kept. Rows read kind first, in country order. Addresses already kept stay, marked, until changed. (#306)
    • The filters above the Applications table are in its column headers: status, outcome and Gone quiet under Status, the tag under Tags or Role; with that column hidden they are under Narrow. Addresses and saved views mean what they did, and the board keeps its form. (#314)
    • A social profile or a code repository is on a service (LinkedIn, Mastodon, GitHub, a Forgejo instance) or Other: chosen first in its row, with an icon, and the address is checked against it. Name is asked only for Other. The archive and the API carry the service, and a plugin can add more. (#305)
    • Language codes are BCP 47 tags in canonical form wherever Postulo writes one: pt-BR and en-GB, not pt-br, and Serbian is sr-Cyrl. If you read them from the API, a webhook, a store plugin or an archive, compare without regard to case or expect the new spelling. Export format 28. (#337)
    • On Your details, Primary is a star and Remove a bin that asks in a dialog and takes the number, link, address or identifier off at once, without Save and without losing what is typed elsewhere. The number that gets you back in cannot be removed, and a copy of the page open in another tab still saves. (#303)
    • On Companies, Applications and Listings the masthead's search box narrows the table, with a button beside it to search everything instead; the page's own box is gone, and Listings can now be searched. On a phone the magnifier opens the same box. (#313)
    • Python 3.12.4 is the oldest Python Postulo runs on: the 3.12 releases before it call some reserved address ranges public. Installed without a container: check python --version before upgrading. (#321)
    • Reminders are kept on the calendar: done, later, edit and delete in every view, a new one from any day, and the agenda narrowed to reminders, overdue ones first, replaces the Reminders page and its navigation entry; its old address leads there. (#316)
    • Postulo's modules import in one direction now, and a test keeps them so. (#248)
    • A table's sort icon sits at the end of its header, so a row of them lines up: on the right in English, on the left in Arabic, with a numeric column's name beside it. A column's resize handle no longer covers part of it. (#317)
    • The forms on Your details and every Settings page stop at the company form's width beside their sidebar, instead of running across a wide monitor; from 1536 pixels a contact's details sit beside its numbers and links, and the capture form beside its help. (#320)
    • A plugin's switch under Server settings is labelled Enabled, and the pills that said Off say Disabled — for a plugin, a repository and a connection; a shipped plugin reads enabled or disabled on your own Plugins page. The amber pills on the server's page are drawn as every other pill is. (#311)
    • The forms under Server settings keep that width too: Sign-in, Email, Capture, Defaults, Data protection, the record of processing and the pages about one person stop at 672 pixels beside the sidebar, while its tables and lists keep the screen. (#320)
    • A page with no structured data is read further: its OpenGraph and Twitter card properties, a title line split into job, employer and place where the page vouches for each, and a salary and closing date in the page's own language. What nothing vouches for stays empty. (#267)
    • The company form uses the whole page: from 1536 pixels wide its details and its identifiers sit side by side, each as wide as the form was, and below that one under the other. (#210)
    • Every CI job is named for what it checks (Unit tests (Python 3.12), Browser tests (Chromium), Release image: build, scan, push), so a failed one says what failed; the release gate follows the new names. (#319)
    • The main navigation follows your own order, set with arrows under Settings → Appearance; each item has an icon, and what the row has no room for is under More. On a phone it is a bar at the foot of the screen, and search is reachable there and from the masthead. (#299)
    • Download draft PDF on a CV or a letter hands back the PDF and files nothing; Export
      PDF
      on an unchanged CV hands back the version already filed. A store is now sent only
      what went with an application, and reports: what it already holds stays, and copies
      still waiting for an export are withdrawn.
      (#236)
    • A figure is drawn one way on the dashboard, the report and the widgets, and the ones that mean something carry a colour: an offer green, a rejection rose, a conversation the brand. (#292)
    • A pill, an alert, a card, a table, a tile and an empty state are each drawn by one component on Basecoat's structure, and a section's heading by one of Postulo's own; the tag, chip and alert-* classes are gone. (#291)
    • Form rows are Basecoat's field family: a label, a box, help and errors in one structure, checkboxes and radios drawn by the stylesheet, and the flag choosers as input groups; the four field-* classes are gone. (#290)
    • CI runs the suite on every core with the browser group left out, measures coverage on one leg against a floor, pins uv, zizmor and pip-audit, and refuses a release or an image unless CI passed on the tagged commit; a monthly workflow proposes lock-file upgrades. (#233)
    • The pages that grew with the size of a search no longer do: search, the board, the companies table, the listings tabs, the report, the figures and bulk tagging each do a fixed amount of work whatever you have recorded. (#231)
    • A table's filters live in the column headers: click a column's name to open its filter, and only the ones in use are on screen. Sorting moved to an icon beside the name, since the name is now the filter. (#253)
    • On an application's page the status, a new timeline entry and a ticked-off reminder change in place instead of reloading the page and returning you to the top. (#257)
    • Installing a plugin keeps the directory as it was, checks that the new one imports before recording it, and puts everything back if it does not; manage.py plugins rollback undoes the last install. Removing one takes the dependencies nothing else uses. (#246)
    • API refusals are RFC 9457 problem documents (application/problem+json, with type, title and status beside detail). A validation failure's field errors moved from detail to errors; a client that iterated detail reads errors now. (#296)
    • The underline on the current navigation link is a choice under Settings →
      Accessibility
      ; the weight and the shade still mark the page when it is off, and a
      high-contrast theme underlines it whatever you chose. (#289)
    • Settings has an Accessibility section holding the career order number and the
      keyboard-shortcuts switch, which were under Appearance. (#281)
    • docs/PLAN.md no longer restates where the project stands and is revised at each
      release. (#255)
    • A changelog entry is one line ending in its issue, where the reasoning lives; the test
      suite holds Unreleased to that. (#254)
    • Each plugin on the administrator's pages has a switch and a "may change it" box instead
      of a four-way dropdown; transports and identifier registries appear, fixed on. (#286)
    • Settings → Plugins no longer names who decided a plugin for your account, and shipped
      rows carry no sentence. (#287)
    • The component layer draws on Basecoat, one structural file at a time under Postulo's own
      paint, starting with the button. (#262)
    • Shared markup is a django-cotton component rather than an include with a with chain.
      (#263)
    • The ESCO classification is no longer committed: manage.py fetch_esco downloads the
      revision it names into src/postulo/jobs/data/, and without the file Postulo runs on,
      matching no titles to no codes. (#266)
    • The foot of every page names this instance and links its source, the help and, set by POSTULO_LEGAL_NOTICE_URL, your legal notice; POSTULO_SOURCE_URL points the source at your own code. The version is shown only to people signed in. (#212)
    • On Your details and a company's form, a kind of identifier another row already holds cannot be chosen again (Other excepted); a duplicate is still refused, naming the kind on its row, and a kind freed by Remove can be taken in the same save. (#307)
    • In European Portuguese an API token is “Token da API”, the wording its maintainer chose in Weblate. (#349)
    • Every list of choices is a button with a list of its own: a flag beside each language and country, an icon beside each kind and each status on the board's cards, time zones grouped by continent, and a box that narrows a long list. Without scripts it is the browser's own list, as before. (#301)

    ✨ Added

    • Record what you sent can email the CV, the letter and the files you chose from your own address when you have a Your own email connection: it sends first, and freezes and records only if the email went. (#361)
    • On Your details and Your career the sidebar's label names the section you are reading once its title has scrolled away. (#677)
    • Your career is in the account menu after Your details, and the menu marks the row of the page you are on. (#676)
    • The Server settings and Settings sidebars stay in view while a long page scrolls. (#699)
    • An administrator adds an instance's own kinds of identifier, in JSON, from Server settings → Plugins: a key, a name per language, a pattern that is not a regular expression, a link. Offered beside Postulo's. Removing one deletes no identifier and changing its pattern refuses none: it is kept as it was, marked. (#311)
    • A CV chooses which of your details it prints: which number, email address, link of each kind and identifiers, the location or not, and your form of address and pronouns if you say so. Nothing changes until you choose; a chosen detail you delete prints none. The API gains PATCH /cvs/{id}; export format 27. (#308)
    • Your name takes a form of address and pronouns, from your career record's language or typed as Other, printed only if a CV says so (#308). A blank location prints the town and country of your primary address on CVs and letters; type one to print that instead. The API gains /profile; export format 26. (#309)
    • A correction on the review screen is remembered where the page showed it, for that site and for you alone: your next capture from it is read there first, each such field says so, a place wrong twice in a row is dropped, and Settings → Capture lists and forgets them. (#267)
    • A skill keeps the ESCO skill its name is: the skill box offers the classification's names as you type, a CV in another language prints its name there where you wrote none, and the export carries it (format 24). Skills already recorded are matched when next saved. (#266)
    • A listing has a history: a message, an email, a call, a note, a file from your documents, or the same advert captured again elsewhere, added on its page or from the capture's review. An application shows it first on its timeline, and it is in your export (format 23). (#270)
    • A token can hold listings:bind, which adds to a listing's history and lists listings to choose one, and nothing else: the scope for a mail client that files messages. Plugins get record_listing_event. (#270)
    • The Europass import reads the PDF europass.europa.eu gives you, through the CV attached inside it (the Candidate XML the platform has written since 2020), and a PDF it cannot read says why and what to upload instead. (#244)
    • A capture can keep the page it was read from: the source as it was parsed, and a rendering sent by the browser extension or drawn from the source. Off until an administrator allows it under Server settings → Capture and you switch it on under Settings → Capture; what is kept is in your export. (#256)
    • An application moved to Rejected, Withdrawn or Ghosted can say why — pay, location, not a match, filled internally, your own choice, or other with a note — then or afterwards; the stage it had reached is read from its timeline, and a widget, Where and why applications end, counts both. (#239)
    • An application can name who referred you and the agency it went through; the posting's company stays the employer, and Where they came from gains By referrer and By agency. Both are in the API, the report's spreadsheet and the archive (format 20), which now carries the people recorded at no company. (#239)
    • A company's or a contact's page says which of your other records look like the same one — the name without its legal form, the website, an identifier, an email address — and Merge shows what would move first, keeps what differs in the kept record's notes and writes on every timeline it touches. (#239)
    • Two versions of a CV or a letter can be compared line by line, from the document's page
      and from an application's documents; a sent CV keeps its words beside its markup, in the
      export too (format 19). A CV frozen before this kept none, and the page says so.
      (#236)
    • A CV can be copied as plain text from a page that needs no script, and downloaded as
      .txt or as a Word .docx, written with no new dependency. Formats are a registry, so
      another is added the same way; none of them is filed as sent. (#236)
    • Your details and your career can be taken out as one readable JSON file and put back, here or on another Postulo: the page shows what it found and what adding it would do, adds what is new and leaves what you already have exactly as it was. The picture is left out. (#181)
    • manage.py check_fonts and Server settings → Overview say which of the scripts the offered languages need this instance can actually draw, and the document themes name their per-script fallbacks; the image's CJK is a build argument, not a default. (#74)
    • Start-up refuses a setting outside its vocabulary, a malformed rate or a bare POSTULO_PUBLIC_URL, with a message naming it. Every log line names its request, scheduler pass or errand, X-Request-ID is honoured and echoed, and POSTULO_LOG_FORMAT=json is there for a parsed console. (#233)
    • A built-in Webhook notifier posts every event as signed JSON to an address you give, delivered by the scheduler with backoff; three new events — a status change, an interview scheduled or moved, an offer recorded — are on for it and off for the notifiers that reach a person. (#240)
    • An offer can be recorded on an application — pay, variable pay, equity, benefits, where, holidays, start and answer-by dates — and every application at Offer is compared side by side, amounts brought to a year within a currency; the answer-by date is on the calendar with a reminder. (#237)
    • A filtered, sorted table can be kept under a name and chosen again from Views, columns included; one view per table can be what the table opens as. A view saved against a table that has since changed still opens and says what it left out. (#259)
    • A CV's preview is on its own page, beside the entries, redrawn after every change; a letter's likewise, and its application picker redraws it in place. (#293)
    • The calendar draws application deadlines and listing closing dates as whole days, the key along the top is also the filter, and the diary file carries both as all-day entries. A new notifier event says when a listing you are considering is close to its deadline — three days' warning unless you change it. (#238)
    • A reminder can be edited, deleted, and put off until tomorrow, next week or a time you choose, from its row and from the API (PATCH and DELETE). Putting one off moves it instead of making a second one. (#238)
    • A tag can take one of seven colours and one of twelve icons, drawn wherever the tag is — the board, the table, an application and the tags page — and the colour box that did nothing is a palette. Colours you typed become the nearest one, or grey. (#285)
    • Your picture is kept at the size you gave it rather than 256 square, so the profile page stops upscaling it, and a Gravatar copy is fetched large enough to show. Pictures already stored stay 256 until re-uploaded or re-fetched. (#265)
    • A company logo or a plugin's mark may be an SVG, sanitised against an allowlist; a raster one is kept at its own size rather than a 256-pixel square. Logos already stored stay square — Refresh re-fetches one with an address, and an upload is replaced by uploading again. (#264)
    • Form fields explain themselves: what goes in the box, in what form, and what Postulo does with it afterwards. (#205)
    • Postulo asks before a page is left with something typed into it and not saved, so a click in the sidebar no longer discards an unsent letter. (#258)
    • Company, Location and Found via suggest what you have already recorded, on the intake form, the listing form and the capture review. (#261)
    • The plugin surface carries the notifier contract, the records a sync works on and the calls that write to them, and postulo.plugins.testing publishes the check that a plugin imports nothing else. (#229)
    • Settings → Appearance offers a compact interface for anybody who wants more rows on a
      screen. Comfortable stays the default, and nothing you can click gets smaller. (#292)
    • Server settings → Design shows every piece the interface is built from, on one page,
      in whichever theme you are reading it in. (#292)
    • The slow things — fetching a posting's page, finding a logo, rendering a PDF, building an
      export archive, telling your notifiers — can be done by a worker instead of while you
      wait, with a page that says what is happening. Off by default. (#247)
    • Listings are a table like the other two: sorted by any column, narrowed per column, with
      the columns you choose, and a bar that shortlists or discards everything you ticked in
      one go. The state tabs and their counts stay above it. (#160)
    • An upload records which language it is in and a sent PDF records the one it declared when
      it was frozen, so a store no longer files a German certificate under the language its
      owner reads Postulo in, and editing a CV no longer rewrites what was sent. (#283)
    • Each CV and cover letter says which language it is written in, as a flag beside its kind
      on the lists and the document's own page, or the language's own name where no flag
      stands for it. (#280)
    • A postal address's country chooser shows the chosen flag, as the telephone field does.
      (#214)
    • POSTULO_UPDATE_CHECK=true lets the scheduler ask the project's release address once a
      day whether a newer Postulo exists, shown on Server settings → Overview; off by
      default. (#272)
    • Settings → Plugins shows each plugin's logo. (#288)
    • Server settings → Defaults shows each language's flag, as the language picker does.
      (#208)
    • Anybody may write an importer: postulo.importers is an open group, Record is on the
      plugin surface, and the contract is on the wiki. (#105)
    • The browser suite reads every page under WCAG's text-spacing override and at 200% zoom.
      (#278)
    • The title box offers the ESCO unit groups in the language read, on the intake form, the
      listing form and the capture review; the posting keeps the ISCO-08 code its title
      matches beside it, on its page, the API and the export (format 18). Postings already
      recorded have no code until their title is next saved.
      (#266)
    • Server settings → Defaults draws a bar under each language, green for strings a speaker has reviewed, amber for drafts and grey for the untranslated, with the three counts in words beside it. (#312)

    ⚠️ Deprecated

    • Plugins: phone_number_is_taken(number) without asked_by= is deprecated and will be removed in a later release. Pass the account the plugin works for: the answer is then charged to that account's allowance of answers about numbers recorded here, and once it is spent every number is answered as taken. (#333)

    🐛 Fixed

    • A company's location written with the country in another language ("Valencia, España", "Köln, Deutschland") now places it: country names come from the CLDR tables of the new babel dependency, in every language Postulo speaks. (#534)
    • Pages that wait on another server (link checks, a logo address, Gravatar, the capture API, connection tests, sending to a store, the mail test) no longer hold the database lock meanwhile; Check them all stops after a minute and says how many links it left. (#357)
    • Restoring a backup on PostgreSQL is all or nothing: the dump is applied by psql in one transaction that stops at the first error, so a dump that fails half way leaves the database as it was. (#477)
    • Deleting an account removes its files only once the deletion commits, so a request that fails afterwards no longer leaves rows whose files are gone; an export whose file has gone from the disk says it has expired. (#355)
    • The phone's bottom navigation bar no longer opens taller than it ends up: the safe-area padding now applies only to an installed app, not to a browser tab whose toolbar already stands under the bar. (#338)
    • Capture now follows a redirected robots.txt (up to five hops) and reads the robots.txt of any other site a posting redirects to, instead of treating a redirect as "no rules". (#364)
    • The GeoNames and ESCO downloads now use the guarded client, the entrypoint follows POSTULO_GEOLOCATIONS_DIR and POSTULO_SKIP_GEONAMES=1 turns the start-up download off; SECURITY, README and PLAN no longer call capture the only outbound request. (#366)
    • Search highlighting and excerpts no longer shift onto the wrong letters after a Turkish dotted capital İ, and long texts no longer return an empty excerpt. (#377)
    • The Applications by month widget now lists every month from your first application to the current one, with nought for a quiet month, and names each month in your language ("février 2026"). (#397)
    • A listing's Apply page now shows the new-tags box its help text promised, and tags typed there are saved with the application. (#408)
    • A portfolio can now be saved in a plugin theme that sets only portfolios; the theme is checked against the document's own kind, not always as a CV. (#412)
    • The test settings no longer read a developer's .env and pin the cache, background work and file log handler, so the suite behaves the same on every machine and an empty POSTULO_LOG_DIR no longer breaks it. (#414)
    • the two tallies on the job-search report no longer share one id in Greek, Cyrillic and other non-Latin languages, so each region is named by its own heading. (#629)
    • postulo-messages now writes the project's own licence, name and issues URL into a plugin's catalogue headers instead of Postulo's AGPL line and tracker; the core's catalogues are unchanged. (#417)
    • A pre-release tag no longer moves :latest or the stable minor image tag; the image workflow takes its tags from release_tools.py, and a +build tag is refused. (#418)
    • The 500 page now speaks the reader's language with a matching lang and dir and is styled under the CSP; a refused CSRF token and a bad request get Postulo's own translated pages instead of Django's. (#421)
    • The demo data is valid and plainly fictional: contact addresses are ASCII, the French CV entry says Paris, and Initech's LEI and the Wikidata ids no longer point at real companies or places. (#423)
    • "Fetch my Gravatar again" no longer shows an unreachable or empty Gravatar as a success: one helper now reports found, none and error as success, info and warning from both places. (#425)
    • Saving Settings → Language and time no longer erases a stored language the instance has withdrawn; the closed row shows the instance default instead of a blank. (#426)
    • The Account page's Two-factor card no longer tells a passkey-only account it has an authenticator app, and now says a password sign-in skips the second factor in a browser marked trusted, for the configured days. (#428)
    • The Account page no longer offers "Add one" for a passkey at an IP address (IPv4 or IPv6, HTTP or HTTPS), where browsers refuse it, and says to use a domain name; IPv6 hosts are no longer garbled. (#429)
    • Armenian and Georgian are now declared in the script table and probed, so check_fonts and the Server settings font status ask whether they can be drawn, and a test derived from the language registry catches the next one. (#455)
    • A telephone or address row now starts on the flag's country for every offered language that has one (Ukrainian, Turkish, Catalan, Welsh and 27 more), not only for EU languages. (#456)
    • A bulk status move on the Applications table no longer records the person's own username as the timeline actor ("via alex"); the event actor stays blank, as on every other browser path. (#410)
    • Deleting an offer now ticks off its outstanding answer-by reminder instead of leaving it on the calendar and announced to the person's notifiers. (#444)
    • A suggestion's action is now only accept or decline; any other word answers 404 instead of silently accepting the suggestion into the timeline and possibly moving the application's status. (#447)
    • Insights: the Interviews column of the sources tables and the Interviews widget now count the same applications: those with an interview entry or that reached Interviewing or Assessment; Screening alone no longer counts. (#449)
    • A calendar narrowed with ?kinds= keeps the narrowing on the day number, the "and N more" link and the week view's day headings, so they open the same set of events. (#452)
    • The calendar no longer reads out an interview nobody came to as "Cancelled interview:" to screen readers; a no-show now has its own spoken prefix, and a cancellation is unchanged. (#453)
    • Removing a store connection now deletes its unsent document copies, so no failed copy is left that can never be sent; sent copies keep their reference, and orphans already left behind are cleaned up. (#511)
    • A waiting capture whose kept page expires after the retention period no longer loses what its review would teach about the site; expiry now saves the places first, as deleting the page by hand does. (#524)
    • The capture form's help now says when the instance is set not to consult a site's robots.txt, instead of always claiming it is honoured. (#630)
    • The email sign-in security tests can now fail: the guess limit, the no-enumeration answer and the second-factor redirect are each checked by behaviour, not by assertions any outcome satisfied. (#631)
    • Connections: a sync report is stored as numbers and worded in the reader's language with plural forms; a private-address refusal says so once, only when it is one; Web Push errors are translatable in the browser plugin's catalogue. (#384)
    • Notifications are worded in the recipient's time zone and language: closing notices count days on their calendar, interview messages show the local formatted time, status titles use their language's label. (#385)
    • A document's sent/created day for stores and the retention report's "recorded" and cutoff dates are now taken in the person's time zone rather than UTC, and the report shows them in the locale's date format. (#386)
    • A cover letter's date at the top is now the same locale-formatted date its {{ date }} placeholder prints, instead of an English "j F Y" pattern; the template lint now also rejects a literal format in {% now %}. (#387)
    • Bulk actions now say "N applications/companies/listings changed." as a real plural form per table, so every language's plural categories and the verb's agreement with the noun are right. (#390)
    • Sentences with a count now use plural forms everywhere ("Added 1 entry.", "1 copy sent.", "1 day", "1 web link"), so languages with several plural forms can translate them; two new checks keep it so. (#391)
    • Translated labels are no longer lower-cased inside sentences: no-show entries, offer terms, sent-document search hits and table filter/sort names now keep the catalogue's capitalisation in every language, and CV stays CV. (#392)
    • The spreadsheet import preview names each status instead of its key, skip reasons and the row-count sentence are translatable with plural forms, the summary shows labelled counts, and the tab title follows the language. (#395)
    • The Browser and Email notifier labels, the "This is before the start date." career error and the unreadable-secrets message now go through translation, so they appear in the reader's language. (#566)
    • Every dated career entry (Experience, Education, Project, Certification) now refuses an end date before its start, and the error message is translatable instead of English in every language. (#621)
    • A company's logo file is now kept until the change to its row commits, so a failed save no longer leaves a missing or orphaned logo; Find logo on a company deleted meanwhile is refused cleanly. (#525)
    • Adding a contact to a department now reuses the company's existing team when the typed name differs only in capitals, instead of creating a duplicate department. (#528)
    • Changing a company's parent now counts the companies already under it, so an ownership chain built from the bottom up can no longer grow past the ten-deep cap. (#532)
    • manage.py fetch_esco now deletes the ESCO files of the revision it replaces once the new ones are written, and manage.py check reports two revisions of either file, so an upgrade no longer breaks saving listings. (#535)
    • A posting field that runs past 500 characters, a NaN, infinite or oversized salary, or a currency that is no ISO code no longer makes the schema.org or board source fail and lose the rest of the posting; only that field is dropped. (#592)
    • A document store is now told the kind's name, and any error sentence it raises, in the owner's language whichever path sends the copy, so Paperless and other stores no longer file one kind under two names. (#605)
    • An imported CV's website and ORCID are now checked as the profile forms check them; a value that fails is named in the import report instead of being stored and printed on the CV. (#610)
    • A CV importer plugin that raises in can_handle is now logged and skipped, and one that raises in read shows a message naming it, instead of a 500 that blocked every upload on the import page. (#611)
    • The career preview now shows your Links (title, type and host) after Projects, so "Everything you have written" really lists every career section. (#612)
    • The listing form's Cancel now returns to the listing when editing, and to the company it was opened for (else the Listings page) when adding, instead of always going to Companies. (#628)
    • Tags and industries: with scripts on, the chip control no longer hides the help text and validation errors of the tag and industry fields (and their new-name fields), and the chip input is described by them. (#522)
    • Fixed: the uploads on an application's Documents page now name their file to screen readers on Download and "Send to stores now", as the other documents pages do. (#516)
    • A portfolio now prints "2020 – present" for a current role or course, instead of the start year alone, in the page, the text and the Word file. (#515)
    • Recording what you sent now offers only reusable-template letters and files that no newer version replaces; a one-off letter or an older version already chosen stays selectable. (#510)
    • Bulk Shortlist, Discard and Restore on listings now count only the rows they changed and no longer overwrite the decision date of listings already in the target state. (#508)
    • Dashboard picker: the blurbs of How long they take, Outcomes and Where they came from now describe what those widgets actually show. (#502)
    • The errand page's screen-reader announcement now works: the live region stays in place and only its contents are swapped on each poll, so "done" and "failed" are announced reliably. (#500)
    • Translation catalogues no longer lose entries whose text holds a backslash before n or t, or a carriage return: the .po reader decodes escapes in one pass and the writer escapes carriage returns. (#498)
    • The translation check now formats every translated form the way the runtime does, so a stray "%" or a dropped positional placeholder no longer gets through and breaks pages in that language. (#497)
    • A refused seed_demo run no longer marks the account's address verified and primary, and vouching for an address (also in createsuperuser) now leaves the account exactly one primary address. (#496)
    • Documents now has a menu on its four lists (CVs, letters, files, sent documents), stays lit on every page of the section, and the Files list can be narrowed by kind like the letters. (#667)
    • The open list of every select is drawn in the colours of its box, in the light and the dark theme, including the column filters, the board's status and the country in a group. (#655)
    • The dashboard's kept figures follow reminders, interviews, the clock and the time zone, so Quiet, In the diary, the interview kinds and the months no longer disagree with the widgets beside them. (#396)
    • A page is never drawn in a language the instance does not offer or nobody has begun translating: a person with no language of their own, or whose language was withdrawn, reads the instance default, and a visitor the browser's language only where it is offered. (#398)
    • The language picker and the bars in Server settings no longer show languages as complete that are not: the progress file is refreshed, and CI now fails when it is stale. (#495)
    • The calendar and the report start the week on Monday in Bulgarian, Bosnian, Greek, Estonian, Irish, Armenian, Icelandic, Luxembourgish, Slovene and Albanian, which Django had starting on Sunday. (#569)
    • The company map no longer reads a capital city as a country: Melbourne, Victoria is placed in Australia instead of being dropped as the Seychelles, and a country is known by its ISO codes and English name. A country written in another language still narrows nothing. (#534)
    • A job title typed in one gender, or with a double space or a decomposed accent, finds its ISCO-08 code: ESCO's développeur de logiciels/développeuse de logiciels is indexed as both forms, and a name that sits in two unit groups gives no code instead of an arbitrary one. (#533)
    • On a page that embeds several postings, the schema.org source picks the one whose url is the page before it looks at identifiers, reads an identifier's value (never the employer's name in it) and matches it as a whole path segment or query value, so 1000 no longer picks the posting of /jobs/10001. (#590)
    • The schema.org source reads a microdata or RDFa posting inside a page-level item (a WebPage on the body, or the posting as its mainEntity): an item that is nobody's property is read wherever it sits, and a posting held inside another is found as in JSON-LD. (#589)
    • Reading a page: an omitted end tag (li, p, dt/dd, td/tr, option) closes the element before it as in a browser, so a list written without them no longer fails every source with RecursionError, and label places like <dt>Local<dd>Lisboa are found. (#587)
    • The duplicate check finds an advert however many listings the account holds; it read only the 200 newest at one path, so older listings on boards that put the job id in the address (Indeed's /viewjob?jk=) were never compared. Addresses are now stored indexed, by a migration. (#556)
    • Put it back: the discarded captures link in the message that follows a discard now opens a list of them, each with a button that restores it; the same page lists every capture waiting, and Listings says how many wait (it said twenty over a longer backlog) and links to it. (#380)
    • A capture that was saved or discarded is decided once: saving it again, from a second click or an old link, no longer makes a second listing, a saved one can no longer be discarded, and its review page shows what it became instead of the form. (#336)
    • A tag or an industry is its name, not an ASCII slug: C# and C++ are two tags, non-Latin names are kept, a renamed tag answers to its new name, and none of it is a 500. Upgrading re-derives the slugs and merges duplicate industries. (#356)
    • A contact's data-protection document, erasure report and retention dry run each account for every record that names them: the applications they are the contact or referrer for, the interviews they were at and the listing-history entries from them. (#370)
    • Saving an application's or an interview's edit page opened before a move no longer undoes it and logs a move nobody made: a status or time that comes back as the page drew it is left as it is now. (#545)
    • Saving an offer without changing its terms (a note, or nothing) no longer writes "Offer revised", sends a webhook, reopens a ticked-off answer-by reminder or undoes a postponement; the reminder follows only a moved date. (#442)
    • Interviews attended (Insights and the employment-office report) no longer counts a no-show, nor a held interview that was moved or settled again; a no-show is its own kind of timeline entry. (#448)
    • Editing an interview no longer drops a contact who has since moved company: they stay on the form, with their company beside the name, until you untick them. (#443)
    • API: a refused POST/PATCH of a company or PATCH of an interview (bad identifier, contact from another company) no longer keeps the changes it made before the refusal. (#435)
    • API: a time sent without an offset (starts_at, due_at, occurred_at, updated_since) is a 422 naming the field instead of a 500 or a reminder moved into the server's zone; /me now says the owner's time_zone. (#567)
    • A scheduled sync now runs in its owner's language and time zone, like Sync now, so the reminders, timeline entries, notes and report it writes name the owner's hour and read in their language. (#335)
    • With a background worker, slow work (reports, frozen letters, exports, captures) is done in its owner's language and time zone instead of the server's, so a report covers the period the page showed and a letter carries the owner's date. (#383)
    • A webhook now receives a reminder that was moved and fell due again, a listing whose closing date moved, and an application that went quiet again, instead of dropping them as repeats; a long quiet or closing list no longer overflows the key column on PostgreSQL. (#413)
    • Status, interview and offer notifications are sent after the change is committed, so a notifier's network call no longer holds the database write lock, and a change that is rolled back is no longer announced. (#578)
    • A webhook whose stored secret can no longer be decrypted (a rotated key, a restored backup) is given up on with the reason on its connection, instead of ending every scheduler pass that reaches it. (#573)
    • A document copy the scheduler is already sending is no longer sent a second time when Send now is pressed: the claim is kept apart from the retry time. (#509)
    • Stopping the background worker (a restart or upgrade) now ends it after the task it is running instead of taking more work until it is killed, and an errand still unfinished after a day is marked failed, so its page stops saying the work carries on. (#474)
    • A slow sort or page answer no longer lands over a newer filter's on Companies, Listings and Applications, and tick boxes are drawn only when there is a bulk bar for them. (#648)
    • Keeping a view, the Columns menu and the Table / Board switch now use the filtered table on screen, not the address the page loaded with. (#623)
    • A career hit in search now opens the career page at its section instead of at the top. (#707)
    • Search's "All N in …" links keep the whole query, open the same N rows they count (timeline text included), and are left out for groups whose list cannot take a query. (#506)
    • Renaming a company through the API refuses an empty name (422) and another company's name in any case (422) instead of saving it or answering 500; accented names in another case are one company. (#546)
    • Company and department names are unique per account in any case on every database, so Émile/ÉMILE and Οτε/ΟΤΕ are one. Upgrading stops, listing them, if two already clash: run manage.py company_name_clashes first and merge each pair. (#546)
    • Search and the text filters now ignore the case of accented and non-Latin letters on SQLite, so école finds École. (#505)
    • The Plugins pages no longer fetch every catalogue's index and signature when they are drawn: an upload is labelled from the checksums the last Check for updates, install or plugins command verified. (#602)
    • Every shipped plugin's description on the Plugins pages is shown in the reader's language: it was turned into English text when the plugin loaded, so its translations were never used. (#633)
    • Install on Server settings → Plugins installs the release of the catalogue whose row was pressed, not the first catalogue to list the name, and plugins install refuses a name several catalogues list unless --catalogue says which. (#601)
    • Server settings → People → Plugins draws a plugin the instance switched off or forced on as it applies to that person and says it follows the instance default; saving it for them stores an exception only where it differs. (#490)
    • A plugin install that runs past the web worker's timeout, or is killed, now puts the plugins directory back as it was instead of leaving an unrecorded plugin loaded; the install and import-check time limits were cut to fit under the timeout. (#604)
    • Upgrading or rolling back a plugin now says that a restart is needed, instead of claiming the new code is already in use everywhere. (#599)
    • Upgrading a plugin that was installed with copies of Postulo's own packages no longer lets it move them: they stay pinned to Postulo's versions, and refusals say "Postulo has". (#598)
    • Removing a plugin no longer deletes a package another plugin still needs, and an upgrade or reinstall keeps the plugin's dependency list, because each plugin now records its whole dependency tree. (#597)
    • plugins rollback no longer switches back on a plugin that was switched off after the install it undoes, and says when a plugin's disabled state changes. (#600)
    • The plugins record is written to a scratch file and moved into place under a lock, and a record that cannot be parsed keeps the plugins that were switched off switched off instead of loading them all again. (#382)
    • Importing a Europass file now spends your allowance of answers about numbers already recorded here, as the forms do: a number held by somebody else is reported in the import's skipped list, and once the allowance is spent a free number is not written either and the report says so. (#615)
    • Once the hourly allowance of answers about recorded numbers is spent, Your details, the contact form and the API refuse every new number, held or not (the API with 429), and contact pages charge the account asking, not a shared allowance. (#374)
    • The Europass import reads the XML Cedefop's editor wrote, with its *List wrappers and hyphenated months, which left the career out; a section it cannot read is named on the review page. (#632)
    • A Europass import places what it adds as an entry typed by hand lands: experience and education by their dates, languages, skills and projects last, with the section's numbers dense. Every imported row used to take order 0 and land under the first entry. (#618)
    • A Europass import no longer fails with a server error when the file's website is a link the account already keeps as a social profile or repository, or its address is one already listed for a contact: that part is left out, said in words, and the rest of the file is added. (#616)
    • A Europass file from Cedefop's editor, XML or JSON, is read as the newer one already was: the markup in an experience's activities, a course's, an achievement's and the skill blocks becomes lines of text, and a listed skill block becomes one skill per item. (#563)
    • Spreadsheet import: a listing keeps its deadline and its tags (in its description), an unreadable deadline is kept in the note, and month names are read in every offered language ("15. Oktober 2026", "5-Jan-26"). (#394)
    • Spreadsheet import: a salary written "45,000 - 55k" is 45,000 to 55,000 rather than 55,000 to 45 million, and an amount written with "R$" is read as Brazilian reais rather than US dollars. (#471)
    • Spreadsheet import: a sheet with no Status column, or a blank Status cell, no longer turns every undated row into an applied application; the date decides, so a list of jobs to look at imports as listings. (#468)
    • Spreadsheet import reads a status or channel by whole words, not substrings, and an outcome wins: "Rejected after interview" and "Offer declined" import as Rejected, "Nothing yet" is kept in a note, "Referral via LinkedIn" is a referral. (#467)
    • Your own email can be set up again: its connection form answered 500 because a secret field's help text was lazy. (#359)
    • A redirect from a connection's server to itself (a relative one, or an absolute one to the same host) is now followed by the server's name rather than the address it was pinned to, so credentials are kept and the next hop's certificate is checked against the name (#363)
    • The diary feed a calendar application can subscribe to (/api/v1/interviews/calendar.ics, with a read token) now carries the deadlines, closing dates and answer-by days, as the downloaded file already did (#451)
    • Reading a pay period from a page no longer takes a "daily", "monthly" or "hourly" earlier in the paragraph for the salary's own period, and a long run of non-ASCII spaces no longer makes the search take minutes (#420)
    • An instance holding the same identifier in two cases (Wikidata Q95 beside q95, or two Other values differing only in case) can now migrate past the identifier-case migration instead of stopping at an IntegrityError (#570)
    • Both compose files now name the :0.3 image, and release_tools.py check refuses a tag whose compose files name another minor, so an install no longer runs the previous release (#403)
    • Source builds have their own override (docker/compose.build.yml, and a PostgreSQL one) that tags the image postulo:local, so docker compose pull can no longer swap a source build for the registry's release; SECURITY.md and the README give one upgrade path per kind of install (#403)
    • The PostgreSQL compose file gains the worker service the SQLite one has, and .env.example names the right file for each, so starting the worker no longer swaps an instance onto an empty SQLite database (#581)
    • The PostgreSQL compose file no longer needs POSTGRES_PASSWORD in docker/.env: it is read from the root .env, and a password with /, ? or % in it reaches PostgreSQL exactly as typed (#582)
    • Saving a table view under a long name cut after a space no longer ends in a server error, and saving the same view twice in a row says "View saved." instead of claiming it needs a name. (#507)
    • Sending a CV or letter for an application with a very long job title and company name no longer fails on PostgreSQL: the "sent to" text and the document title are cut to their columns. (#513)
    • A document whose title holds a double quote or a line break now downloads correctly instead of sending a malformed header or answering 500. (#376)
    • The calendar and the report no longer answer 500 for a hand-edited address at the edge of the calendar (year 9999, 0001 or 20266-Q3); they fall back to today, and only years 1900 to 2200 are read. (#389)
    • Django admin: adding a person now asks for a username and full name, and the change form, list and search show the username, so a second person no longer ends in a 500; signup's username rules apply there too. (#427)
    • New application, new listing and capture review, and the listing API, now refuse a salary currency that is not three letters and store it in capitals, so the listing's edit form no longer rejects it later. (#446)
    • An identifier pasted with an unbalanced "[" in its address is now refused with the format message instead of crashing the page, API call or import with a server error. (#460)
    • A spreadsheet import with a cell over 128 KiB, usually from an unclosed quotation mark, now says the file could not be read instead of giving a server error. (#462)
    • A long spreadsheet file name no longer overflows the timeline's actor and summary columns, which made the import fail on PostgreSQL. (#463)
    • Spreadsheet import no longer mistakes "Applied through" or "Deadline date" for the applied date, so the channel, deadline and real "Date applied" columns are guessed correctly in any column order. (#465)
    • Editing a reminder that was put off with Later or Snooze no longer announces it a second time when only its wording changes; those times are now stored to the minute, as the edit form shows them. (#445)
    • Moving an application's status now decides applied_at and closed_at from the locked row, so two concurrent moves no longer leave an open application closed, re-stamp the applied date, or leave a withdrawn one without a close date. (#543)
    • Outbound connections (HTTP guards, mail test and sending) now try each approved address in turn, so a name with one dead address, such as a broken IPv6, no longer fails whenever that address comes first. (#547)
    • A webhook delivery whose receiver's hostname briefly fails to resolve is now retried with back-off instead of being given up on and blamed on the private-address setting. (#549)
    • Webhooks no longer follow a redirect, which re-sent the POST as a bodyless GET and could record the event as sent; a 3xx now fails the delivery and the Test button, naming the address to use instead. (#550)
    • Putting a reminder off until tomorrow or next week now keeps the hour it was set for, in your own time zone, instead of the minute you pressed the button, and no longer shifts an hour across a clock change. (#565)
    • A browser notice older than a week is no longer shown to the next tab you open; expired and already-shown notices are also cleared on the scheduler pass, not only when a new notice arrives. (#577)
    • A deactivated account no longer gets due reminders, webhook posts, syncs or document copies sent on its behalf; its queued rows wait and resume if the account is reactivated. (#575)
    • A scheduler pass that outlives its lease no longer deletes the next pass's lease, and a sync connection is claimed before it runs, so overlapping passes no longer sync it twice. (#576)
    • Two runs of one sync connection no longer overlap: Sync now during a scheduler pass, or from two tabs, reports that a sync is already running instead of duplicating contacts. (#586)
    • Rolling back documents 0005 now carries copies back instead of failing, and accounts 0021 now refuses to be undone up front, since invitation tokens cannot be recovered from their fingerprints. (#571)
    • The archive now keeps single-key shortcuts, nav underline, density, plugins switched off, capture-keeping switches and closing notice, and a restore brings back a switch set off or a cleared dashboard instead of the defaults. (#464)
    • Importing an archive into an account whose profile already has a primary number, address, link or identifier now keeps the profile's rows and reports what it skipped, instead of failing with a traceback. (#375)
    • An account import now reports every telephone number it leaves out because another record on the instance already holds it, naming the profile or contact and the last two digits, so the report no longer reads clean. (#562)
    • An account archive from a newer Postulo, or one whose format is not a known number, is now refused up front with a message naming both formats, instead of a traceback or a silent partial import. (#561)
    • The container restore advice now stops every service whatever its Compose profile, the background worker included, and starts the same profiles again afterwards, in restore --help and in the "something else is using the database" refusal. (#541)
    • Restore now refuses an archive written by a newer Postulo (naming the version to install) or a database with unknown migrations, unless the new --allow-newer flag is given. (#479)
    • The retention dry run on Server settings → Data protection now reports on the number typed in the retention field, falling back to the saved limit when it is invalid or empty, and still saves nothing. (#483)
    • Saving the Sign-in or Defaults page no longer overwrites a stored setting that an environment variable pins; all four policy forms now share one rule for dropping pinned fields. (#492)
    • Choosing "Not set here" on a Server settings policy select (registration, single sign-on as second factor, capture options) now stores "unset" instead of silently saving "No". (#493)
    • Server settings → Plugins: a posted action the page does not have, such as a stale or hand-built form, now gets the polite refusal instead of an Internal Server Error. (#484)
    • Uploading, cancelling or confirming a plugin package now removes only that administrator's own waiting package, so another administrator's package under review is no longer thrown away; abandoned ones are swept after a day. (#485)
    • Server settings: the four per-account pages (username, recovery link, delete account, plugins) now mark People as the current sidebar section (aria-current), as the other sections' sub-pages already did. (#486)
    • A mistyped mail provider or grant, or Google with the application grant, in the environment is now refused at start-up by a new check (postulo.E008) when POSTULO_EMAIL_AUTH is xoauth2; a blank provider is left to the Email page. (#487)
    • A plugin whose name another kind already holds is now left out and logged, so one policy switch, form id and logo can no longer govern two plugins (a source and a notifier sharing a name). (#593)
    • Fixed a broken logo image on Settings → Plugins for plugins a person (or an administrator) switched off: the logo is now served to anyone the plugin is offered to, on or off. (#594)
    • manage.py plugins disable and remove now refuse the package that carries the selected mail transport, as the plugins page always did; the lock moved into the install code both use. (#595)
    • Settings → Plugins now shows the Uploaded or Custom tag on an installed plugin whose package name differs from its own, as every official plugin's does; before, only built-ins were tagged. (#603)
    • The plugin surface check now records what a plugin imports by name: from postulo.plugins import api passes, and an allowance for postulo.core.site no longer lets postulo.core.models through. (#606)
    • A plugin switched off or removed now takes its document themes, template and translation directories with it at once, instead of staying in the pickers and breaking Export until a restart; an upgraded plugin shows its new logo. (#609)
    • Importing a Europass file with an address without a country, an over-long number, an impossible year or a malformed website now reads what it can or is refused with a sentence, instead of ending in a server error. (#634)
    • Importing an old Europass XML or JSON file now reads the country codes UK and EL as GB and GR, and no longer cuts a three-letter code such as PRT down to another country (#639)
    • Europass import no longer silently cuts content: an untitled entry keeps its whole description, a heading's skills are no longer capped at forty, and a JSON Skills block of the wrong shape is reported as unreadable (#640)
    • Europass import: skill group headings (Digital, Job-related, ...) are now written in the language the file states for the CV, not the interface language of whoever imports it (#641)
    • The address notes are now whole translated sentences, one per label ("Post to this country usually needs a postcode."), so "a address" and "A Eircode" are gone and no label or English country name is pasted into a sentence. (#642)
    • A company register number typed without a country code (HRB 12345, KVK 12345678) is no longer split into invented letters and saved; it is refused with the example, as is a prefix that is not a country code. (#636)
    • An ISNI is now checked against its ISO 7064 check character, so a mistyped one is refused like an ORCID, and its link is built without spaces; an already-stored ISNI with a wrong check character is refused when its form is next saved (#637)
    • Identifiers: accented/percent-encoded LinkedIn names, 2-3 letter ResearcherIDs and Web of Science links are accepted; a pasted Scopus link keeps only its author ID; a Wikidata link's fragment is ignored; non-ASCII digits are refused. (#638)
    • A malformed identifier now reads "ORCID: that is not the usual form (for example …)" instead of the ungrammatical "a ORCID identifier"; the person and company identifier checks now live once in core. (#645)
    • Paginated API lists (applications, companies, CVs, letters, interviews, reminders) break ties by id, so paging can no longer repeat one row and skip another. (#440)
    • GET /api/v1/captures takes after_id, so a client can catch up past a run of captures sharing one updated_at. (#439)
    • The API refuses blank names for companies, listings, applications, contacts, reminders and letters, and renaming a company onto another's name is a 422, not a 500 or a duplicate. (#438)
    • Each letter's language is pinned in the API by a test, and the schemas no longer default it to an empty string. (#437)
    • PATCH /api/v1/interviews/{id} no longer answers 500 when starts_at, location or notes is null; null means unchanged, as on the other patch calls. (#436)
    • A capture's Idempotency-Key longer than 200 characters is refused with a 422 instead of a 500 on PostgreSQL. (#433)
    • The OpenAPI description lists the refusals a call raises itself: 413 on every call with a body, 409 on POST /captures, 404 on POST /interviews and POST /reminders. (#431)
    • An unknown API address, a non-numeric id and a method an address does not take are answered as problem documents (401 without a token, 404 with one, 405 with Allow), not HTML. (#430)
    • A non-numeric ?application= or ?company= on the new reminder, contact and posting pages is ignored, and accepting a suggestion with a non-numeric application answers 404, instead of a server error. (#409)
    • /logs and /metrics answer 401, not 500, when the bearer token holds a non-ASCII character. (#372)
    • Choosing a NACE division with a comma in its name from the Other industries suggestions adds that division, with its code, instead of splitting it into made-up industries. (#531)
    • Your career: a skill saved without a group is no longer lost from every page. The form now requires a group, Add a skill under a group opens it with that group chosen, and skills already without one are listed in their own block. (#617)
    • Deleting a job or any other entry from Your career now takes its place on every CV with it, instead of leaving a row that made the CV, its entry pages and the API answer 500; rows already left behind are removed on upgrade. (#381)
    • A CV with include contact details off no longer carries the holder's name in its PDF title, its file name, the draft's name or the DOCX title: it is titled by its kind alone. (#512)
    • Resizing a column, applying Columns or pressing Reset no longer deletes your saved views, and resizing under an applied view no longer overwrites your own columns. (#503)
    • Answering Stay to the unsaved-work question no longer leaves the button you pressed dead: Discard this capture or Sign out works again on your next key or click. (#518)
    • A discarded listing stays under Discarded after its closing date passes, instead of moving to Closed still labelled Discarded; the tab counts, the API filter and the insights figure agree with the State column. (#529)
    • A company form with a rejected identifier no longer says Company added. or Company updated., and no longer fetches, replaces or removes the logo of a company it did not save (a logo address on a new company was a 500). (#530)
    • Moving a contact who is in a department to another company, or to none, saves instead of answering 500; the contact leaves the old team, and a team typed in the box joins the new company. (#527)
    • In the Docker image the ESCO classification lives on the data volume (POSTULO_ESCO_DIR, /app/data/esco): fetch_esco used to fail with a permission error writing into the image, and a file forced in with -u root was lost at the next upgrade and never seen by the worker. (#411)
    • Removing a tag or industry chip with the keyboard keeps focus in the control instead of dropping it to the top of the page. (#519)
    • Server settings → Plugins lists the own-mail outbox, so an administrator can switch off people sending mail through servers they type, as the code and the refusal always said. (#608)
    • Check it answers on a link whose host refuses HEAD no longer downloads the whole file behind it into memory to read the status: the second request is streamed and closed unread. (#619)
    • In a right-to-left language the plugin switches draw their knob inside the track, at the end that says on or off, instead of hanging outside it. (#517)
    • The report filed with Download declares its language and reads from the right edge in Arabic and Hebrew: the filed PDF used to say lang="" and dir="ltr", unlike the draft. It is now drawn in the language of the person who pressed the button. (#568)
    • A group of tick boxes or radio buttons drawn through the form field component, such as the two lists on Freeze and attach, now has a name and its help text for a screen reader: it is a fieldset with a legend, not a label attached to nothing. (#624)
    • The counts on the company and application delete pages (3 postings, 2 interviews, ...) are now translated: they were passed to ngettext through variables, so no catalogue held them. A test now fails on any translation call whose text is not a literal. (#388)
    • A saved view can be named in any script (Greek, Cyrillic, Arabic, Ge'ez and the rest): it was refused as nameless, and two names sharing a number replaced each other. A name with no letter or digit now says so. (#504)
    • The Tags list on an application's forms has a name with scripts off: its heading is now a real label for the list. (#625)
    • A mistake in the plugin repository form on Server settings → Plugins now shows the form with its error, instead of a blank 405 Method Not Allowed page. (#491)
    • Importing an archive keeps the links on a CV: they were dropped as "no such record" and the CV came back without its portfolio and profile lines. (#470)
    • A contact's telephone rows no longer offer Gets me back in when a text gateway is installed: the choice belongs to your own numbers on Your details. (#650)
    • Saving Your details or a contact untouched no longer stores an empty telephone row where the country chooser starts on a country. An empty row saved before this is on the page, and can be removed there. (#649)
    • The telephone box tells a screen reader that its number was refused and why, and is described by its help: it carries the aria-invalid and aria-describedby every other field has. (#416)
    • On Your details, the box for the state of a postal address reads Bundesstaat in German and Πολιτεία in Greek, not the word for a status, and so in 28 more languages: the address rules' own translation of State is the one drawn. (#651)
    • On Your details, changing one postal address into another that is removed or changed in the same save no longer answers with a server error, and an address one of your contacts already holds is refused on its row, with the reason, instead of by the database. (#458)
    • With Address rules by country switched off for you, or for everybody, the address rows on Your details keep their neutral names and say nothing about a country; the switch had no effect on that page. (#635)
    • An API refusal's detail is a translated sentence every time, in the language of the account that owns the token and not the client's, and carries request_id, the id in X-Request-ID and in the log. What a call writes, such as an interview's reminder, is worded and timed for its owner too. (#393)
    • Erasing a contact no longer ends in an error page when a plugin's eraser fails. It is refused, with nothing removed, while a plugin holds rows about the person that it cannot remove; and a plugin that fails during an export, a merge or an erasure is logged. (#371)
    • /logs?since= answers with the oldest records after that time, so a collector that asks again carries on from where it stopped; it used to hand over the newest limit and skip the rest for good. since is compared as a time whatever its offset, and one that is not a time is a 400. (#475)
    • The kept log keeps what POSTULO_LOG_BACKUPS promises. Every process rotated postulo.log on its own, so one rotation became several and most of the history was lost; they now rotate one at a time, and rotations are read in order past the ninth. (#379)
    • A table whose filters match nothing keeps its header, so the sort and the filters are still there to change and are not forgotten by the next search; and narrowing Companies while it is kept to one group (?group=) no longer drops the group. (#647)
    • A table opened with a filter already in its address (a saved view, a bookmark, Back) can have that filter changed or cleared from its column header again; it stayed stuck until Clear. On a phone, Narrow now has its own button and applies when pressed, not as you type. (#622)
    • Emptying a column's filter in a table header, or choosing Any there, no longer folds the filter shut around the box you are in: it stays open with the focus in it, so the next word typed narrows the table again. (#626)
    • A telephone number or a link removed from one row and added to another is saved in one go: the new row was refused, the number in the words meant for somebody else's and at the cost of a rationed answer. Two rows exchanging their values are each told it is already listed: two links answered with a server error. (#461)
    • The same address listed under two kinds of link, a GitHub profile as a code repository and as a website, is saved as two links on Your details and on a contact: the page answered with a server error and lost everything typed on it. An address is still listed once under each kind. (#457)
    • The Chromium PDF renderer counts as usable only when its browser is installed, not the playwright package alone, and one that cannot start says how to install it instead of answering a draft with an error page. (#514)
    • A webhook connection says how its last delivery went: a receiver answering 404 no longer reads as working once another event is queued, and an address refused at delivery is said on the connection. (#574)
    • The container's own health check is answered whatever POSTULO_ALLOWED_HOSTS holds. With only the public host named it was refused with a 400, so a working container was reported unhealthy and the scheduler and worker never started. (#580)
    • Settings → Account → Passkeys → Manage opens: it answered with a server error for everybody who had a passkey, so one could not be renamed or removed. (#424)
    • A Norwegian reader is given the occupation and skill names ESCO publishes in Norwegian, where the English ones were shown; and a language menu keeps a language it does not list, where saving a CV or a letter wrote blank over it. (#337)
    • Your career → Other languages saves a translation under a language and nothing else: a mistyped address used to store it under whatever was typed, and answered with a server error on PostgreSQL. (#620)
    • On Your details, a telephone number or a postal address typed into the empty row is saved: the page answered with a server error instead, and lost everything else typed on it. (#454)
    • Server settings → Defaults shows every language ticked while nothing has been narrowed, as the page says, and saving it that way still stores nothing; a partly translated language keeps its region in the list, "français (France)". (#322)
    • A refused language list on Server settings → Defaults no longer blanks the name, language and time zone above it, so saving that card afterwards cannot wipe the stored defaults. (#494)
    • Going Back to Companies, Applications or Listings after narrowing or sorting the table could show the table alone, with no title, masthead or styles. Those pages now say that an htmx request gets a different answer, so the browser's cache no longer hands back the fragment. (#646)
    • A capture is refused with its reason when the site redirects to something that is not an address, or its name stops resolving between two lookups; the API answered 500 and the page said only that something went wrong. A page naming a character set that is not one is read as UTF-8. (#607)
    • A row's menu on Server settings → People is no longer cut off inside the table: every menu opens over the page, beside its trigger, and upwards where there is no room below. Each person there has their picture too. (#310)
    • The "Name, if Other" box appears only when the kind is Other, and a name given another
      kind is blanked on save. (#284)
    • Every third-party work in the tree carries its notice, htmx is pinned and synced like the
      rest, THIRD-PARTY.md is the register, and Postulo states its copyright. (#279)
    • Tables carry a hidden caption, and discarding a capture links to where it went. (#260)
    • Under Windows High Contrast every control keeps a border and the funnel bars their value;
      a page printed from the dark theme comes out as ink on white. (#277)
    • Row actions name their row, tooltips became text, the invitation link is whole, new-tab
      links say so, fields about you carry their autocomplete purpose. (#276)
    • Every sideways-scrolling table is a named stop the arrow keys scroll, the failure alert
      can be dismissed, and the column-resize handle is visible before hover. (#275)
    • A field's border and focus ring clear 3:1 in both themes, the current section is
      underlined, and nothing is said by colour alone. (#274)
    • Five lists and grids that kept the reading measure now take the screen. (#273)
    • On Companies, a company's name opens the company, and renaming it where it sits is the
      pencil beside the name. (#252)
    • Nine catalogues wrote one in the plural that also counts twenty-one; the forms are
      real now. (#250)
    • The image workflow asks the registry for the tags it pushed and attaches the bill of
      materials to the release. (#251)
    • Export my data and Import carry the reminders that are about no application, which were left out of the archive (format 30). Older archives still import. (#334)
    • The archive carries the files sent with an application and a sent document's sent to line, so a restored account keeps what went with each application and a sent PDF keeps its place (format 31). (#469)
    • manage.py restore onto a database that was never migrated, the state POSTULO_SKIP_MIGRATE=1 leaves, restores instead of ending in a traceback. (#476)
    • manage.py restore counts the SMTP password, the mail consent and the transports' secrets under the field key, not only connections, and no longer says nothing is lost when they cannot be read. (#481)
    • Expired export archives, and captured pages past their days, are deleted by the export page too, at most once an hour, so an instance without the scheduler no longer keeps every archive for good. (#539)
    Downloads