Correct what translating the handbook found wrong in it

Six defects, all found by somebody reading a page closely enough to
render it in another language. Each is fixed in English and in German
together, and the German markers are re-hashed only where the German
prose now says what the corrected English says — re-hashing alone would
mark a stale translation as current, which is the one thing that check
exists to prevent.

  calendar-model.md      claimed the materialiser honours "exactly four
                         things in a patch" above a table of five, and its
                         traps section named a different four. The truth
                         is five keys read, of which four are the patch
                         EventInstanceEditor writes; `excluded` is not a
                         patch field at all and is spelled by
                         RecurrenceRuleConverter::exclusionOverrides(),
                         because the one override value whose only job is
                         to be exactly right should not have two places
                         that must be right about it.

  CLIENT_DEVELOPMENT.md  said snooze was "not exposed over JMAP today" and
                         offered itself as the canonical "ask, don't work
                         around" case — while Thread/set has been
                         implementing exactly that. Somebody asked. Its
                         methods table was also missing Thread/set,
                         SearchSnippet/get and all four calendar methods,
                         which the same page announces two screens above.

  install/docker.md      described the missing blob volumes as a live
                         defect. They were added to compose.yaml hours
                         after that page was written — by the report that
                         found them — and nobody updated the page. It now
                         documents ten volumes and keeps the story as
                         history, with a pointer to the CHANGELOG note for
                         anyone upgrading past it.

  providers/imap-smtp.md named a form label no interface shows. The
                         hardcoded labels in AccountType are overridden by
                         the Twig template, so the fields are "Email
                         address" and "Username".

  settings.nav.calendars had no German, so one item in a German settings
                         menu rendered in English.

  CONTRIBUTING.md        said "four translators" where there were six.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-05 22:41:59 +02:00
1 parent 629d937d97
commit b4e3cb6afb
10 files changed
+90 -99

No files matched your search

+1 -1
View File
@@ -622,7 +622,7 @@ Two things hide the register and are worth checking for after a draft: passive v
avoid addressing the reader at all, and "man" standing in where "du" reads better. Mixing the two
forms is worse than either consistently, so a page converted halfway is a page not yet converted.
**German glossary**, so four translators do not invent four words for one thing:
**German glossary**, so a handbook translated by several people does not invent several words for one thing:
| English | German |
|---|---|
+13 -8
View File
@@ -32,15 +32,14 @@ concept that lives in the database but not in the API, a limit that's wrong for
Concretely, **stop and ask before** you:
- reimplement server logic client-side because the API doesn't expose it (snooze is the live example
— see [§4](#feature-parity-checklist));
- reimplement server logic client-side because the API doesn't expose it;
- scrape or drive the HTML / Turbo Stream routes because JMAP lacks something;
- poll aggressively to compensate for a missing change-tracking method;
- invent local state (custom keywords, shadow flags, local-only labels) that can't round-trip;
- denormalise or cache something in a way that would break if the server later did it properly.
Things this document flags as **not implemented** — `Email/queryChanges`, anchor paging, JMAP
Contacts, JWT issuance, snooze over JMAP, a cross-account unified query — are all *candidates for
Contacts, JWT issuance, a cross-account unified query — are all *candidates for
being built*, not permanent constraints. Raise the need with the maintainer and decide together
whether it belongs in the server or the client.
@@ -552,6 +551,10 @@ Everything registered in [`src/Jmap/Method/`](../src/Jmap/Method/):
| `Mailbox/get` / `Mailbox/query` / `Mailbox/changes` / `Mailbox/set` | |
| `Email/get` / `Email/query` / `Email/changes` / `Email/set` | |
| `Thread/get` / `Thread/changes` | |
| `Thread/set` | plMail extension. One property, `snoozedUntil` — see §4. |
| `SearchSnippet/get` | |
| `Calendar/get` | `urn:plmail:params:jmap:calendars`. One account serves calendars. |
| `CalendarEvent/get` / `CalendarEvent/query` / `CalendarEvent/set` | An id is the series, not an occurrence; `/query` requires a date window. |
| `EmailSubmission/get` / `EmailSubmission/set` / `EmailSubmission/changes` | |
| `Identity/get` / `Identity/set` | |
@@ -874,11 +877,13 @@ Ordered roughly by how much users will miss them.
- Labels: apply, remove, create, delete. Nested labels exist in the data model; nested label *UI* is
still on the server roadmap, so flat-with-paths is acceptable.
- Archive = remove Inbox label. Trash = `destroy`. Both undoable.
- Snooze — bring a conversation back later. This is a **thread-level** property
(`MessageThread.snoozedUntil`) that exists in the database and in the web UI, but is **not exposed
over JMAP today**. This is the canonical "ask, don't work around" case: the data and the semantics
already exist server-side, so the fix is a small vendor extension, not a client-side reimplementation.
A locally-tracked snooze would silently disagree with the web UI and break on reinstall.
- Snooze — bring a conversation back later. A **thread-level** property (`MessageThread.snoozedUntil`),
exposed as `Thread/set`, which is a plMail extension accepting that one property and nothing else.
It goes through the same `ThreadSnoozeService` the web UI does, so a snooze set from a client and
one set in the browser mean the same thing — which is the point, and why a locally-tracked snooze
is still the wrong idea: it would disagree with the web UI and break on reinstall. This section
used to say snooze was not exposed at all, and cited itself as the canonical "ask, don't work
around" case; somebody asked.
- Mark read/unread, star.
**Settings**
+16 -11
View File
@@ -1,4 +1,4 @@
<!-- translated-from: CLIENT_DEVELOPMENT.md sha1:d3361366991bf0c0c036c986fb164e5040f73b00 -->
<!-- translated-from: CLIENT_DEVELOPMENT.md sha1:e8b507a009a17b4a74b8b3d040491e1aa38aaf35 -->
# Einen Client für plMail bauen
Alles, was eine Entwicklerin (oder ein Agent) braucht, um einen *neuen* plMail-Client zu schreiben
@@ -38,8 +38,7 @@ ein für Mobilgeräte falsch gesetztes Limit:
Konkret: **halt inne und frag nach, bevor du**
- Serverlogik clientseitig neu implementierst, weil die API sie nicht herausgibt (Zurückstellen
ist das lebende Beispiel — siehe [§4](#checkliste-zur-funktionsgleichheit));
- Serverlogik clientseitig neu implementierst, weil die API sie nicht herausgibt;
- die HTML-/Turbo-Stream-Routen scrapest oder fernsteuerst, weil JMAP etwas nicht hat;
- aggressiv pollst, um eine fehlende Methode zur Änderungsverfolgung auszugleichen;
- lokalen Zustand erfindest (eigene Keywords, Schatten-Flags, nur lokale Label), der nicht
@@ -48,7 +47,7 @@ Konkret: **halt inne und frag nach, bevor du**
ordentlich täte.
Was dieses Dokument als **nicht implementiert** kennzeichnet — `Email/queryChanges`,
Anchor-Paging, JMAP Contacts, JWT-Ausgabe, Zurückstellen über JMAP, eine kontoübergreifende
Anchor-Paging, JMAP Contacts, JWT-Ausgabe, eine kontoübergreifende
vereinigte Abfrage — sind allesamt *Kandidaten für den Bau*, keine dauerhaften Einschränkungen.
Bring den Bedarf beim Maintainer zur Sprache und entscheidet gemeinsam, ob er in den Server oder
in den Client gehört.
@@ -609,6 +608,10 @@ Alles, was in [`src/Jmap/Method/`](../src/Jmap/Method/) registriert ist:
| `Mailbox/get` / `Mailbox/query` / `Mailbox/changes` / `Mailbox/set` | |
| `Email/get` / `Email/query` / `Email/changes` / `Email/set` | |
| `Thread/get` / `Thread/changes` | |
| `Thread/set` | plMail-Erweiterung. Eine Eigenschaft, `snoozedUntil` — siehe §4. |
| `SearchSnippet/get` | |
| `Calendar/get` | `urn:plmail:params:jmap:calendars`. Kalender liefert genau ein Konto. |
| `CalendarEvent/get` / `CalendarEvent/query` / `CalendarEvent/set` | Eine ID ist die Serie, nicht eine Termininstanz; `/query` verlangt einen Zeitraum. |
| `EmailSubmission/get` / `EmailSubmission/set` / `EmailSubmission/changes` | |
| `Identity/get` / `Identity/set` | |
@@ -961,13 +964,15 @@ Grob danach geordnet, wie sehr Nutzerinnen sie vermissen werden.
*Oberfläche* für verschachtelte Label steht noch auf der Server-Roadmap, flach mit Pfaden ist
also in Ordnung.
- Archivieren = Posteingangs-Label entfernen. Papierkorb = `destroy`. Beides rückgängig zu machen.
- Zurückstellen — eine Konversation später zurückholen. Das ist eine Eigenschaft **auf
Konversationsebene** (`MessageThread.snoozedUntil`), die es in der Datenbank und in der
Web-Oberfläche gibt, die aber **heute nicht über JMAP offengelegt ist**. Das ist der
kanonische Fall für „frag nach, bau nichts drumherum": Die Daten und die Semantik gibt es
serverseitig bereits, die Lösung ist also eine kleine Herstellererweiterung und keine
clientseitige Neuimplementierung. Ein lokal geführtes Zurückstellen widerspräche stillschweigend
der Web-Oberfläche und ginge bei einer Neuinstallation kaputt.
- Zurückstellen — eine Konversation später zurückholen. Eine Eigenschaft **auf
Konversationsebene** (`MessageThread.snoozedUntil`), offengelegt als `Thread/set`, einer
plMail-Erweiterung, die genau diese eine Eigenschaft annimmt und sonst nichts. Sie läuft über
denselben `ThreadSnoozeService` wie die Web-Oberfläche, ein aus einem Client gesetztes
Zurückstellen bedeutet also dasselbe wie eines im Browser — genau darum geht es, und genau
deshalb ist ein lokal geführtes Zurückstellen weiterhin die falsche Idee: Es widerspräche der
Web-Oberfläche und ginge bei einer Neuinstallation kaputt. Dieser Abschnitt sagte früher, das
Zurückstellen sei gar nicht offengelegt, und führte sich selbst als kanonischen Fall für
„frag nach, bau nichts drumherum" an; jemand hat nachgefragt.
- Gelesen/ungelesen markieren, mit Stern versehen.
**Einstellungen**
+23 -37
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/docker.md sha1:1136c2c336cb10040b2c5e588a7cebc63fb2d930 -->
<!-- translated-from: install/docker.md sha1:5382118760ac4e687b2aebee31c9d4910c6e6f65 -->
# Installation mit Docker Compose
Der unterstützte Weg, von Anfang bis Ende: was du brauchst, was `docker compose up` tatsächlich tut,
@@ -148,50 +148,36 @@ sollte.
## Speicher, und was die Standarddatei nicht dauerhaft ablegt
Die Compose-Datei deklariert sechs benannte Volumes:
Die Compose-Datei deklariert zehn benannte Volumes:
| Volume | Enthält |
|---|---|
| `app_secrets` | `generated.env`, `postgres_password`, das JWT-Schlüsselpaar. Von **jedem** Anwendungsdienst eingehängt und nur lesend in `database` und `mercure` |
| `database_data` | Den PostgreSQL-Cluster |
| `app_secrets` | `generated.env`, `postgres_password`, das JWT-Schlüsselpaar. Von **jedem** App-Dienst eingehängt, in `database` und `mercure` nur lesend |
| `app_attachments`, `app_raw`, `app_uploads` | Anhänge, Rohnachrichten und zwischengelagerte JMAP-Uploads. Von jedem App-Dienst eingehängt, denn die Worker schreiben sie und der Web-Container liefert sie aus |
| `database_data` | Der PostgreSQL-Cluster |
| `caddy_data`, `caddy_config` | Caddys TLS-Material und Zustand |
| `mercure_data`, `mercure_config` | Zustand des Hubs |
| `ntfy_data` | Zustand der Benachrichtigungs-Topics |
Diese Liste hat eine Lücke, die du kennen solltest, bevor du echte Mail hineinlegst:
**`var/attachments`, `var/raw` und `var/uploads` liegen in der Standard-`compose.yaml` auf keinem
Volume.** Das Dockerfile enthält bewusst kein `VOLUME /app/var/` — ein anonymes Volume dort gab
jedem Container seine eigene Kopie, sodass Anhänge, die ein Sync-Worker schrieb, für den
Web-Container unsichtbar waren, der sie ausliefern soll —, und die dauerhaften Pfade sind
stattdessen pro Dienst zu deklarieren, wie es `compose.override.yaml.dist` und
`truenas.compose.yaml` beide tun. Füge sie dem Dienst `php` und allen vier Workern hinzu, wenn
Anhang-Downloads überleben sollen, dass ein Container neu erzeugt wird:
Die drei Blob-Volumes fehlten bis vor Kurzem, und der Fehler war auf lehrreiche Weise lautlos. Das
Dockerfile hat bewusst kein `VOLUME /app/var/` — ein anonymes Volume dort gab jedem Container seine
eigene Kopie —, die dauerhaften Pfade werden also pro Dienst deklariert. Die beiden
Deployment-Dateien, die tatsächlich jemand bearbeitet hat, `compose.override.yaml.dist` und
`truenas.compose.yaml`, deklarierten sie. Die Standarddatei `compose.yaml`, also genau die Datei,
zu der die README greifen lässt, nicht. Anhänge, die ein Sync-Worker schrieb, waren damit für den
Web-Container unsichtbar, der den Download ausliefern sollte, und beide Kopien starben beim
nächsten `docker compose up`, das einen Container neu anlegte. Nichts warf einen Fehler: Mail kam
an, die Liste zeichnete sich, und der Download lieferte 404.
```yaml
services:
php: &blobs
volumes:
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
worker-ingest: *blobs
worker-export: *blobs
worker-maintenance: *blobs
imap-supervisor: *blobs
**Wenn du eine Standard-`compose.yaml` von vor dieser Korrektur betreibst, lies den
Deployment-Hinweis in
[CHANGELOG.md](https://github.com/karatektus/pl_mail/blob/main/CHANGELOG.md), bevor du ziehst.**
Ein leeres Volume über ein Verzeichnis zu hängen verdeckt, was darin liegt — gelöscht wird nichts,
aber die Dateien sind nicht mehr sichtbar, bis sie herüberkopiert sind.
volumes:
app_attachments:
app_raw:
app_uploads:
```
`truenas.compose.yaml` geht den anderen Weg: Es setzt `APP_STORAGE_DIR=var/data` und bindet ein
einziges Host-Verzeichnis auf `/app/var/data`, sodass Anhänge, Rohnachrichten, Uploads und die
Geheimnisse alle unter einem Pfad landen — eine Sache zum Snapshotten, eine Sache zum Sichern.
**Der typische Fehlerfall verläuft lautlos.** Nichts meldet einen Fehler: Mail wird abgeglichen, die
Liste wird gerendert, und der Download eines Anhangs endet mit 404, weil die Datei in einem
Container liegt, den der Webserver nicht sieht — oder in einem Container lag, der inzwischen ersetzt
wurde.
`truenas.compose.yaml` geht einen anderen Weg: Es setzt `APP_STORAGE_DIR=var/data` und bindet ein
einziges Host-Verzeichnis an `/app/var/data`, sodass Anhänge, Rohnachrichten, Uploads und die
Geheimnisse alle unter einem Pfad landen — eine Sache zum Snapshotten und eine zum Sichern.
## Alltagsbetrieb
+10 -5
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/calendar-model.md sha1:bf151a9ad94f1b595778248245a7ed79a2ddae60 -->
<!-- translated-from: internals/calendar-model.md sha1:7c82dbb22e19aa9d49c9de3588e586b79b7a940a -->
# Das Kalendermodell
JSCalendar in jsonb, mit den abfragbaren Teilen in Spalten herausgezogen; die Tabelle der
@@ -195,8 +195,12 @@ stabile Art zu sagen „die, die am 3. sein sollte", nachdem sie auf den 5. gezo
das, was eine zweite Bearbeitung derselben Instanz ihren Patch aktualisieren lässt, statt einen
neuen danebenzustapeln.
Der Materialisierer beachtet in einem Patch genau vier Dinge, und
`App\Service\Calendar\EventInstanceEditor` schreibt genau diese vier:
Der Materialisierer liest fünf Schlüssel aus einem Override. Vier davon sind der Patch, den
`App\Service\Calendar\EventInstanceEditor` schreibt — die vier Dinge, die eine Termininstanz
überhaupt zeichnen kann —, und der fünfte ist gar kein Patch-Feld: `excluded` wird von
`RecurrenceRuleConverter::exclusionOverrides()` geschrieben, denn der eine Override-Wert, dessen
einzige Aufgabe es ist, exakt richtig zu sein, sollte keine zweite Stelle haben, die ihn richtig
treffen muss.
| Schlüssel | Wirkung |
|---|---|
@@ -360,8 +364,9 @@ der Nutzerin. Alles, was einen Override erzeugt, muss `RecurrenceMaterialiser::z
statt selbst eine Zone zu bestimmen.
**Alles, was ein Patch über `start`, `duration`, `title` und `status` hinaus sagt, wird
ignoriert.** Der Materialisierer liest diese und sonst nichts, ein „reichhaltigerer" Patch sind
also Daten, die die Speicherung überstehen und nie eine Ansicht beeinflussen.
ignoriert** — diese vier plus `excluded`, das auf dem anderen Weg oben ankommt. Der
Materialisierer liest sie und sonst nichts, ein „reichhaltigerer" Patch sind also Daten, die die
Speicherung überstehen und nie eine Ansicht beeinflussen.
**Den Horizont zu verlängern ist nicht umsonst, und ihn zu verkürzen kostet Erinnerungen.** Der
Horizont der Termininstanzen ist zugleich der Horizont der Erinnerungen — eine Erinnerung gibt es
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: providers/imap-smtp.md sha1:23ff0340249508460944b7e7704fc313c8f01213 -->
<!-- translated-from: providers/imap-smtp.md sha1:b4f879ff7cebf8fb604297a9d39a979025c8ea9f -->
# IMAP und SMTP
+16 -30
View File
@@ -133,49 +133,35 @@ running.
## Storage, and what the stock file does not persist
The compose file declares six named volumes:
The compose file declares ten named volumes:
| Volume | Holds |
|---|---|
| `app_secrets` | `generated.env`, `postgres_password`, the JWT keypair. Mounted by **every** app service, and read-only into `database` and `mercure` |
| `app_attachments`, `app_raw`, `app_uploads` | Attachments, raw message sources, and staged JMAP uploads. Mounted by every app service, because the workers write them and the web container serves them |
| `database_data` | The PostgreSQL cluster |
| `caddy_data`, `caddy_config` | Caddy's TLS material and state |
| `mercure_data`, `mercure_config` | Hub state |
| `ntfy_data` | Notification topic state |
That list has a gap worth knowing about before you put real mail in it: **`var/attachments`,
`var/raw` and `var/uploads` are not on any volume in the stock `compose.yaml`.** The Dockerfile
deliberately has no `VOLUME /app/var/` — an anonymous volume there gave every container its own
copy, so attachments written by a sync worker were invisible to the web container that serves them
— and the durable paths are instead expected to be declared per service, which
`compose.override.yaml.dist` and `truenas.compose.yaml` both do. Add them to the `php` service and
to all four workers if you want attachment downloads to survive a container being recreated:
The three blob volumes were missing until recently, and the failure was silent in an instructive
way. The Dockerfile deliberately has no `VOLUME /app/var/` — an anonymous volume there gave every
container its own copy — and the durable paths are instead declared per service. The two
deployment files that people actually edited, `compose.override.yaml.dist` and
`truenas.compose.yaml`, both declared them. The stock `compose.yaml`, which is the file the README
tells an operator to run, did not. So attachments written by a sync worker were invisible to the
web container serving the download, and both copies died with the next `docker compose up` that
recreated a container. Nothing errored: mail synced, the list rendered, and the download 404'd.
```yaml
services:
php: &blobs
volumes:
- app_attachments:/app/var/attachments
- app_raw:/app/var/raw
- app_uploads:/app/var/uploads
worker-ingest: *blobs
worker-export: *blobs
worker-maintenance: *blobs
imap-supervisor: *blobs
**If you ran a stock `compose.yaml` from before that fix, read the deployment note in
[CHANGELOG.md](https://github.com/karatektus/pl_mail/blob/main/CHANGELOG.md) before pulling.**
Mounting an empty volume over a directory hides what is inside it — nothing is deleted, but the
files stop being visible until they are copied across.
volumes:
app_attachments:
app_raw:
app_uploads:
```
`truenas.compose.yaml` takes the other approach: it sets `APP_STORAGE_DIR=var/data` and binds one
`truenas.compose.yaml` takes a different approach: it sets `APP_STORAGE_DIR=var/data` and binds one
host directory at `/app/var/data`, so attachments, raw messages, uploads and the secrets all land
under a single path — one thing to snapshot and one thing to back up.
**The failure mode is silent.** Nothing errors: mail syncs, the list renders, and an attachment
download 404s because the file is in a container the web server cannot see — or was in a container
that has since been replaced.
## Everyday operation
```bash
+8 -5
View File
@@ -173,8 +173,11 @@ is the same fact in the occurrence row: the only stable way to say "the one that
be on the 3rd" once it has been dragged to the 5th, and what makes a second edit of the same
instance update its patch instead of stacking a new one beside it.
The materialiser honours exactly four things in a patch, and `App\Service\Calendar\EventInstanceEditor`
writes exactly those four:
The materialiser reads five keys out of an override. Four of them are the patch
`App\Service\Calendar\EventInstanceEditor` writes — the four things an occurrence row can actually
draw — and the fifth is not a patch field at all: `excluded` is spelled by
`RecurrenceRuleConverter::exclusionOverrides()`, because the one override value whose only job is
to be exactly right should not have a second place that has to be right about it.
| Key | Effect |
|---|---|
@@ -323,9 +326,9 @@ LocalDateTime in the *series'* zone; a floating event's zone is UTC, not the use
producing an override must ask `RecurrenceMaterialiser::zoneOf()` rather than resolving a
zone itself.
**Anything a patch says beyond `start`, `duration`, `title` and `status` is ignored.** The
materialiser reads those and nothing else, so a "richer" patch is data that survives storage
and never affects a view.
**Anything a patch says beyond `start`, `duration`, `title` and `status` is ignored** — those
four plus `excluded`, which arrives by the other route above. The materialiser reads them and
nothing else, so a "richer" patch is data that survives storage and never affects a view.
**Extending the horizon is not free and shortening it loses alerts.** The occurrence horizon
is also the alert horizon — an alert exists only where an occurrence row does — and
+1 -1
View File
@@ -18,7 +18,7 @@ connection to come up, and about the handful of settings providers genuinely dis
carry a short note about something that provider requires. The list is matched against the domain
of the address you type, so for most mailboxes the settings arrive before you have touched the
dropdown at all.
2. **Account label** is what the account is called in plMail's own interface. **Email address** is
2. **Email address** is what the account is called in plMail's own interface. **Username** is
what the server knows you as, and is also what the provider detection reads. On most servers
these are the same string; on some they are not, which is what the two fields are for.
3. **Password** is the mailbox password, or — at most large providers — an application-specific
+1
View File
@@ -1680,6 +1680,7 @@ settings:
aliases: Aliase
app_passwords: App-Passwörter
appearance: Darstellung
calendars: Kalender
general: Allgemein
profile: Profil
filters: Filter