32 Commits
Author SHA1 Message Date
jo2904andClaude Opus 5.5 5550727888 AppLocale : proposer le français
Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent
rester synchronisés (le commentaire de translation.yaml le dit). Le
sélecteur de langue des réglages et celui de l'installation lisent
AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre
modification.

Deux réglages suivent la langue et devaient connaître le français :
- ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures
  par défaut comme l'allemand. Sans ça, un utilisateur francophone qui
  n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest.
- Le sélecteur d'emoji charge ses noms et mots-clés par langue (la
  recherche « cœur » ne trouve rien dans les données anglaises).
  assets/emoji/fr/data.json vient de emoji-picker-element-data, comme
  en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet
  pour octet à ceux publiés par le paquet, donc fr/ vient bien de la
  même source. Le gabarit choisit maintenant de, fr ou en d'après la
  langue au lieu du seul test « starts with 'de' ».

Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le
contenu des mails selon leur langue, et non la langue de l'interface.
Apprendre les dates françaises (« le 4 août ») est un autre chantier.

Docs : README, features/appearance.md (et sa traduction allemande, avec
le hash mis à jour), et une entrée de CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
2026-10-13 23:33:38 +02:00
Paul LütznerandClaude Opus 5.5 ff87ee92d7 Release v0.3.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:08:49 +02:00
3c7a03b843 Talk to an OpenAI-compatible endpoint as well as Ollama
The language model had to be an Ollama container. Admin → AI now has a
Provider field, and the second choice is the request format OpenAI
published and nearly everything else has copied: llama.cpp, vLLM, LM
Studio, LiteLLM, the hosted providers, and Ollama itself under /v1.

OpenAiClient speaks three paths, /chat/completions, /embeddings and
/models, and answers in the value objects and error categories
OllamaClient already uses. AiAssistant reads the connection for the
feature being served and picks the client; nothing above it knows there
are two. The body carries only what the format defines, because several
servers answer 400 to a field they do not know. The stream is read a line
at a time, skips comment lines, does not require [DONE], and fails on an
error object delivered inside a 200. Redirects are never followed.

What only Ollama can be asked is not asked of the other dialect:
no keep-alive is sent, the composer is not told to expect a cold start,
Warm up refuses with its own reason, and the panel reads no /api/ps. The
page puts those controls away while the other provider is chosen and
keeps their values.

A stored token is dropped when the provider changes, as it is when the
address does. One column on ai_settings, defaulting to ollama; the
migration is reversible.

The SSE handling, the strict reading of an embedding and the rule that a
key stays with its endpoint come from #58.

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:54:49 +02:00
Paul LütznerandClaude Opus 5.5 a8aea33874 Open a fresh install from the LAN, not only from the machine it runs on
The stock compose file served https://localhost and nothing else. Reached
by address or hostname, Caddy had no certificate to offer and ended the
handshake, which a browser reports as an SSL protocol error with no way
to proceed. A headless server could not be set up without first setting
SERVER_NAME.

The default now answers every name and address with the self-signed
localhost certificate (default_sni/fallback_sni, and `:443` in the default
SERVER_NAME), so the browser shows its usual warning.

Live updates follow: compose.yaml's https://localhost hub address is
treated as the placeholder it is and gives way to the public address
stored during setup. Any other MERCURE_PUBLIC_URL, and any SERVER_NAME an
operator sets, is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:58:54 +02:00
Paul LütznerandClaude Opus 5.5 99b2bd1478 Link the Android and iOS apps from the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:27:21 +02:00
Paul LütznerandClaude Opus 5.5 9c4cebf8b9 Stop rewriting the cache directory at every start
Starting plMail took over two minutes on a NAS with spinning disks, and
the log was silent for all of it — between "Statement statistics are
enabled" and the web server's first line. An empty install on the same
box, from the same image, was up in seconds.

The entrypoint ended with two `setfacl -R` passes over var/cache and
var/log. var/cache is part of the image: about 2,200 files. Changing
anything about an image file from inside a container, an ACL included,
makes the storage driver copy the whole file into the container's layer
and flush it. So every new container rewrote its cache one synchronous
file at a time, in every container of the stack at once, on one pool.

Measured in a fresh container on an SSD: 1.2 s for the first pass,
0.03 s for the second. On the warm production container, 0.06 s — which
is why timing it there showed nothing.

It ran only as root, so the catalogue install (uid 568) never paid it.
Nothing needed the entries: nothing runs as www-data, root needs no
grant, and the Dockerfile already makes both directories writable.

This is the third time that block has been the answer to a slow start.
It walked all of var/, then var/cache and var/log, and now nothing.

Also, on the compose files here: the database health check had no
interval, so Docker's first probe came thirty seconds after start and
the web server and workers could not begin before it. Five seconds now,
with retries raised so a database in trouble is called unhealthy after
the same two and a half minutes as before.

The README sends TrueNAS users to the catalogue app instead of walking
them through truenas.compose.yaml. The file stays: installs made from
it are still running.

Not confirmed on the box that reported it; the copy-up cost is measured
locally and inferred there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:07:40 +02:00
Paul LütznerandClaude Opus 5.5 e6868482c1 feat(calendar): answering an invitation shows the meeting in the calendar
Yes or Maybe redrew the card and put a toast in the corner, and the
calendar pane beside it went on showing the week without the meeting
until a reload: the response replaced the card and nothing told the
pane.

calendar--invite makes the calendar part of the answer, in three layers
that each work without the next:

- Preview. Hovering or focusing Yes or Maybe outlines the slot the
  meeting would take, dashed, in its calendar's colour, at every motion
  level. The slot is worked out in the browser from the card's UTC
  instants on the grid's own clock (data-grid-zone), full width; the
  real block takes its lane when it arrives.
- Flight. At Full motion the pressed answer arcs to that slot and turns
  into the meeting on the way, or shrinks into a dot on the calendar
  button when the pane is closed. It reads --motion-travel, a new
  MotionLevel token: 580ms at Full, zero at Minimal and None and under
  prefers-reduced-motion. DESIGN.md names it the second exception to the
  260ms ceiling, beside new mail.
- Redraw. Once the server has answered, the pane is fetched and morphed
  in place with Idiomorph, since a pane embedded at page load has no src
  to reload, and the real block is lit where the placeholder stood. A
  screen reader hears where it went.

Nothing waits on the iTIP reply the server sends first: the button takes
its answer at once and the flight lands on a placeholder the real block
replaces. A failed request takes all of it back.

A meeting answered Maybe is striped in its calendar's colour with a
dashed edge, in every view, and says "Maybe" in its accessible name.
Nothing drew a tentative meeting differently before. Only the reader's
own answer stripes it, not an event's STATUS.

app:test:seed-invite hangs an unanswered invitation on seed-mail's
"E2E Read Me". calendar-invite.spec.ts answers it at Full (Yes flies,
no reload) and under reduced motion (Maybe outlines a striped slot,
nothing flies, the block is striped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:27:37 +02:00
Paul LütznerandClaude Opus 5.5 6d5c8c57b7 docs: the README's drop targets include Starred and Unread
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:23:43 +02:00
Paul LütznerandClaude Opus 5.5 463931507d docs: the README says plMail announces newer builds; the admin shot has Updates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:57:14 +02:00
Paul LütznerandClaude Opus 5.5 00e6cd90fb docs: the README shows the icon picker, and every screenshot is retaken
Two new captures — an icon chosen in its own colours, and the same icon open
on the colourway grid — with the topbar in both frames, since where the icon
ends up is the point of choosing it. The rest are retaken against the demo
mailbox as usual before a tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:14:13 +02:00
Paul LütznerandClaude Opus 5.5 e9c5f2940b Release v0.2.39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:11:23 +02:00
Paul LütznerandClaude Opus 5 626a7ccf53 Release v0.2.34
Search stops burying what it found under what it guessed, and starts showing
which words a result matched on. The message source view serves the original
bytes it has had on disk all along, and the copy button beside it runs. A list
navigation is answered with the list rather than with a whole document: a search
that cost 29 database round trips costs 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:18:59 +02:00
Paul LütznerandClaude Opus 5 9835756aec Make the calendar hold together at every width
Seven columns used to divide whatever width there was, so a phone — or a narrow
docked pane — gave each of them about fifty pixels, and a block showed its
colour and little else. A column has a floor now: below the width where seven
of them fit the grid pans sideways under an hour gutter that stays pinned, and
four days are legible where none were. The time-grid shell's own header costed
that fix as disproportionate once — "three scroll containers kept in step by
JavaScript" — and it is not, because the three grids already share one
scroller; all that was left was making each one's first cell sticky.

A BLOCK IS TWO ROWS NOW. The time on one line with the calendar's colour as a
bar down the left, the title under it, which is a whole title in a 96px column
where one line was four characters of each. Day view keeps the single line and
is told so rather than measured for it: a day column is the whole grid and is
never the width that makes stacking worth doing.

The agenda's date rail is gone. A day is a heading with its events under it,
and the times sit in a lane that is a grid track rather than a fixed guess —
sized against every time in the view, so it is 43px where an "all day" needs it
and 33px where nothing does. The month gives up its words entirely below 5.5rem
of cell and draws one bar per entry, which is what a phone can honestly say in
forty pixels, and "N more" is gone along with a count that a variable cell
height could never make true; the date is the link into the day instead.

The grid opens on now rather than on a fixed 07:00, and a chip too small for
its own title opens to it while the pointer rests on it. Width only: growing
the height made the chip inside stop following its parent, and a click aimed at
the middle of a Day-view block landed under the button and opened nothing.

Two bugs fell out of the work. An all-day entry sorted below the morning east
of UTC, because its start is a floating midnight in UTC while a timed one's is
an instant — the time grid never showed it, since it puts all-day entries in a
band of their own and never asks. And a grid block had never had a background
at all: an inline colour was overriding the surface class written beside it, so
the only paint it carried was a tenth of a calendar colour over whatever
happened to be behind it.

Opening on now has one consequence worth naming: a fixture at 10:00 is on
screen all morning and scrolled above the viewport by the afternoon, and
`boundingBox()` answers for an off-screen element with coordinates that land
under the pinned day headings. calendar-timegrid's drag helpers scroll the
block into view before measuring it now; without that the suite passed for
hours and then did not, and the failure read as "dragging is broken".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:52:00 +02:00
Paul LütznerandClaude Opus 5 376cf4ae72 Add Paperless-ngx as an attachment source and a save destination
Connect it with an address and an API token and it appears beside the other
services; nothing else in the UI had to change, because the picker, the
destination chooser and the connect dialog are all driven by the provider
taxonomy and the driver interfaces.

TAGS ARE THE CONTAINER LAYER. A document archive has no directory tree —
Paperless replaced folders with tags and expects the rest to be found by
searching — so the picker's "descend into a container, pick a file" has
nothing to descend into. The root lists every tag as a folder with the newest
documents beneath it, and opening a tag lists that tag's documents.
Correspondents and document types would serve equally well and are
deliberately not offered: three sideways slices of one archive is a navigation
problem, and search already crosses all three.

Search is Paperless's own full-text index, so it reads the OCR layer as well
as the metadata and a scanned invoice is findable by a line item nobody typed.
That is the feature: there is no longer a reason to open Paperless, find a
document and download it before writing the mail.

Upload is asynchronous — post_document answers with a task id, not a document
id — so upload() returns it prefixed. Ids are opaque outside the driver, which
is what makes the prefix safe, and download() refuses a task id with a
sentence a person can act on rather than letting it 404 later.

ShareLink is not declared. Minting a Paperless share link is a permissioned
feature with its own expiry semantics, not something to do silently. The
taxonomy test that assumed only photo services lack share links had a false
premise; it now states the real rule rather than being relaxed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:51:58 +02:00
Paul LütznerandClaude Opus 5 4201fd26d4 Release v0.2.21
Two things a self-hoster could not see before, and one they could see far too
much of.

Account health now recognises Google's weekly Testing-mode expiry rather than
offering "sign in again" for the fifth time, and warns before the next one. The
admin panel grew a card for browser errors, filtered so it holds this
application's faults and not the extensions injected into the page beside it.
And a dead sign-in stopped writing a CRITICAL every quarter of an hour, which
is what made the log worth reading again.

README gains the health line — the panel had never been mentioned there at all,
having quietly become one of the more useful parts of the app. Screenshots
untouched: nothing in the seven views they show changed this cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 10:24:38 +02:00
Paul LütznerandClaude Opus 5 41362290bb Release v0.2.20
Twenty-six commits since v0.2.19, of which the changelog had one entry — the
rest written up here.

The handbook needed two corrections rather than additions. features/mail.md
still said a sender you have written to is pulled into Primary "regardless of
any bulk-mail header", which stopped being true when the mailing limit went in
and directly contradicted the paragraph two sections above it. features/admin.md
described Reported mail as one pile of missed insights with a scope drop-down,
which is now two kinds in one list with a selection. Both German pages follow,
and both markers are re-hashed.

README gains the sorting choice and says the summary can take a whole
conversation. Screenshots are untouched: nothing in the seven views they show
changed this cycle — the new interface is in Settings and in Admin → Reported
mail, neither of which is photographed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:42:52 +02:00
Paul LütznerandClaude Opus 5 67a71bf8c7 release: v0.2.19 — a label labels, and a folder that exists is not a refusal
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:49:20 +02:00
Paul LütznerandClaude Opus 5 b49c56f75d release: v0.2.16 — mail goes where you drop it, and the assistant takes new instructions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:20:48 +02:00
Paul LütznerandClaude Opus 5 d8f8acf29b feat(ai): tunable keep-alive per model, and a warm-up button
The first "Help me write" after a quiet spell shows nothing for tens of
seconds while the Ollama host reads twenty gigabytes back off disk. How
long it waits before evicting a model is OLLAMA_KEEP_ALIVE — an
environment variable on a machine plMail can talk to but cannot
configure. The API takes the same setting per request and a per-request
value wins, so this is the only lever available from inside the app.

Two nullable columns on ai_settings, one per model, because the two are
opposite trades: the writing model is eighteen gigabytes and asked for
occasionally, the embedding model a few hundred megabytes and asked for
on every search and every indexed message. Both ship at 5m, which is
Ollama's own default, so an upgrade changes nothing about how a host
behaves — the boxes just say out loud what was already happening. -1 is
recommended for the search model in the help text and the handbook
rather than shipped, because plMail cannot see how much memory that host
has. Empty means send no keep_alive at all and let the host decide,
which is a real selectable state and not an unfinished one.

The wire representation is the part that is easy to get wrong: Ollama
parses a JSON string as a Go duration, and a Go duration needs a unit, so
"-1" and "3600" as strings are a 400 on every model call from a setting
that reads as correct. KeepAlive::forBody() is the only thing that puts
the value in a body — int for the numeric spellings, string for the
durations, null to omit the field entirely, since an explicit null is
read as zero and unloads immediately. One helper adds it to all four
bodies so no call site can get that wrong.

Validated with the same expression the body is built from, refused
rather than coerced, and the message lives in the validators domain
where Symfony actually looks for it.

Warm up now: POST /admin/ai/warmup issues the no-prompt /api/generate,
times the round trip and reports it. PHP's 30s execution limit is raised
for it — a cold load is not 30 seconds — and the upstream timeout sits
below that so it gives up first. A host that is unreachable and a model
that is not on the host are distinct, translated answers.

Also carries both columns through the configuration backup, and adds the
AI feature to the README, which had never mentioned it.

Fixes a search test that asserted how fast the machine was: it gave the
last-resort body pass a one-millisecond budget and one row to scan, so on
quick hardware the pass finished in time and the test failed. It now
provokes a genuine statement_timeout cancellation with pg_sleep, which
exercises the same guard without a clock in the assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:54:09 +02:00
Paul LütznerandClaude Opus 5 f9a5a17f7e docs: a README you can read in five minutes, and the handbook to match
The README had grown to 318 lines and explained how to register a Google Cloud
Pub/Sub topic before it had finished saying what plMail is. That belongs in the
handbook, which is where it already was. What is left is 101 lines: the
handbook link first, what it is, the highlights, the screenshots, and the two
ways to install it — Docker, and TrueNAS as dataset, edit one path, paste.

The lead paragraph under the H1 is load-bearing, not decoration:
bin/build-site.php lifts the first paragraph into the landing page's hero, so a
README that opened with the badge row put raw markdown there and overflowed the
phone layout.

The handbook follows the two behaviour changes in this branch — the send pill
being the cancel, and an account row opening that account's inbox — in both
languages, with the German hash re-pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:47:57 +02:00
Paul LütznerandClaude Opus 5 8a918a95e8 docs: the compose window as it actually is, and a page for what is broken
Everything shipped since v0.0.33 that a person using plMail can see, in both
language trees.

MAIL. The "Composing" section had drifted furthest and is rewritten around
what the toolbar now does: the emoji picker (full Unicode, localised search,
skin tones, recents — and the rule the whole feature is built on, that `:)`
and `:smile:` survive typing), inline images as `cid:` parts that are not
attachments and are not counted as any, the link popover and its second face
on an existing link, signatures and the three states that matter, scheduled
send with its one-minute floor and thirty-day ceiling read on the user's own
clock, and the more-options menu including the honest disabled encrypt button.

Read receipts get their own section rather than a line, because the receiving
half is a privacy default: Never, per address, with ask and always as opt-ins,
a receipt only ever sent on a read the user performed themselves, and an
automatic send downgraded to "ask" when the return address disagrees with the
sender.

The "New" marker is documented as the different axis it is — shown, not
opened, and never longer than 24 hours.

HEALTH. New page, both languages, linked from the index and from accounts:
what the five problem classes mean for a person's mail, and reconnect-in-place
— the repair that keeps mail, labels, rules, threads and calendars, and is
refused outright when the re-consented identity is a different address or
provider. Plus calendar sync backing off instead of retrying forever.

ACCOUNTS. Display order and the primary account are separate now, so the page
says so: dragging is cosmetic, "Make primary" is the control, and the coloured
dot is an identity colour that no longer moves — status lives on the health
page. The old trap said the opposite and promised no such button was coming.

CALENDAR. The grid is drawn in the user's configured zone rather than UTC, and
an event saved to a hidden calendar appears in no view — the editor lists
hidden calendars, the views do not.

Two things found already wrong and corrected in passing: mail.md claimed how
far back a mailbox syncs is a per-account setting, and README.md's tour caption
offered to "choose how much history to sync". No such setting exists on
Account, and accounts.md said so on the same page.

Both trees end in sync, every translated-from hash re-stamped.
phpunit OK (3462 tests, 8985 assertions) — the baseline exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:31:06 +02:00
Paul LütznerandClaude Opus 5 af3265a045 Make a translation possible, and default the site to Solar
Three things, all in service of the German handbook that follows.

**A language is a directory.** docs/<locale>/ with the same paths as the
English tree, and the site renders a language from the ENGLISH page list,
falling back to the English page for anything not translated and saying so
in a banner in the reader's own language. Without that fallback a language
is all-or-nothing, which means it is never begun; with it, the first page
translated is the first page somebody reads in German and the rest still
resolve. It also makes deleting a bad translation a safe fix rather than a
hole.

**A translation says what it was made from, and a test checks.** Each
translated file opens with `<!-- translated-from: <path> sha1:<hash> -->`
and DocumentationCoverageTest re-hashes the English. This is the only
check here aimed at truth rather than inventory, and it gets at it
sideways: nobody can test whether a German paragraph still describes the
code, but a translation made from a page that has since changed is a claim
about the past, and that is checkable. A stale translation is worse than a
missing one — the missing page admits it, the stale one is fluent and
wrong.

A content hash, not a git blob hash and not a commit date. Dates mark
every translation stale after a whitespace sweep, and git is unusable from
the test container, which refuses this repository for dubious ownership —
a failure this suite has already had once.

The other checks are now locale-aware: a translated page is not required
to appear in the English index (its position is its path), its traps
section is looked for under the fixed heading its language uses, and its
links resolve against the ENGLISH tree — which is what the site does too,
and what stops a partial translation demanding that every page it links to
is already translated.

**Solar is the site's default theme**, and "System" now means something.
There was a [data-theme="dark"] block and no prefers-color-scheme query at
all, so System was light on every machine — a switcher option that
silently did nothing. The media query is scoped :not([data-theme]) so an
explicit choice still out-ranks the operating system, or Solar would lose
to a dark-mode laptop.

The README now points at the handbook above the fold, phrased as a link to
the handbook rather than to "our website" — the same line is the top of
the landing page that README generates, where "visit the site" would be
addressed to somebody already on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:10:44 +02:00
Paul LütznerandClaude Opus 5 4d103cfbdf Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.

The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.

Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.

Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.

  compose.yaml            mounted no volume for var/attachments, var/raw
                          or var/uploads. Workers write those during sync
                          and the web container serves them back, so each
                          kept its own copy in its own layer — downloads
                          404, and everything died on a recreate. The
                          override and the TrueNAS file both mount them;
                          the file the README tells an operator to run did
                          not. The same omission cost real data once
                          before. See the CHANGELOG for moving what is
                          currently stranded.

  app:calendar:materialise  new, nightly. Occurrences are drawn when an
                          event is saved and nothing moved that window
                          again, so a weekly standup ran out of dates in
                          two years and its reminders stopped with them —
                          silently. The query it runs had existed since
                          the materialiser did, documented as exactly this
                          sweep, and had never been called.

  Microsoft wizard        asked for three of the nine permissions plMail
                          requests, and a token already issued is not
                          upgraded when the rest are added later.

  GmailWatchService       told you to grant Pub/Sub Publisher to
                          gmail-api@system.gserviceaccount.com. The
                          account is gmail-api-push@; a topic granted
                          exactly what the docblock asked for never
                          delivered a notification.

  IcsExporter             said it wrote no VALARM. It has written them
                          since alerts landed.

  CLIENT_DEVELOPMENT.md   said there was no JMAP calendar API and that
                          SearchSnippet/get was absent. Both shipped.

  CONTRIBUTING            said only app:calendar:push registers a push
                          channel; subscribing has done it since the
                          subscribe flow learned to.

  Three docblocks         named getters removed long ago, or described a
                          method two below the one they sat on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:13:25 +02:00
Paul LütznerandClaude Opus 5 4a8dc7d151 Photograph the filters and the calendar, on a mailbox anyone can seed
The README tour was missing both screens, and could not gain them: the
suite that captures it needed a demo installation exactly one person had.
The E2E fixtures cannot stand in — "E2E Trash Me" is the right name for
an assertion and the wrong one for a picture in a readme — so the
screenshots could only be retaken by that person, and only they could
tell whether they still matched the app.

app:test:seed-demo writes a mailbox that reads like mail: ten threads
with senders, subjects and labels dull enough to be believable, none of
it anyone's real correspondence, which is what the caption under those
images promises. It also clears the fixture account and any leftover
calendar events, so the same command twice gives the same picture.

The calendar events are created through the dialog rather than seeded. An
event is a JSCalendar object whose occurrences are materialised on write,
and going around the writer to put rows in the database is how a
screenshot ends up showing a state the app cannot produce.

One bug the camera found: the live match count read "Matches 1 existing
messages". The number is substituted in the browser, so the string now
carries both forms and the builder picks one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:08:42 +02:00
Paul LütznerandClaude Opus 5 a7fbea24a7 Bring the README up to what plMail actually does
Four things had shipped and gone unmentioned: rules, the calendar pane,
the file integrations, and what the search box now understands. The admin
section describes the queue panel as it is, since "the first place to
look when mail stops arriving" is only true if it can tell a stuck queue
from an empty one.

Screenshots left alone deliberately. The two the tour is missing —
filters and the calendar — have to be taken on demo data; the caption
under them promises that, and this database is real mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:26:22 +02:00
Paul Lützner c6ae79450a 2fa 2026-07-31 07:18:26 +02:00
Paul Lützner f0e6782f28 now fix docker 2026-07-31 05:12:08 +02:00
Paul Lützner 122006460d onboarding. this is a lot 2026-07-30 16:40:31 +02:00
Paul Lützner dbc02c1bfd encryption 2026-07-28 17:45:41 +02:00
Paul Lützner d0685d0ee6 update 2026-07-28 12:33:46 +02:00
Paul Lützner cd3c78c205 update 2026-07-21 13:40:57 +02:00
Paul Lützner 76f890a49e init 2026-07-02 13:12:28 +02:00