-
Notifications
You must be signed in to change notification settings - Fork 0
De Client Development
Alles, was eine Entwicklerin (oder ein Agent) braucht, um einen neuen plMail-Client zu schreiben — eine native iOS- oder Android-App, eine Desktop-App, ein CLI, ein weiteres Web-Frontend —, ohne vorher die gesamte Symfony-Codebasis zu lesen.
Es geht um drei Dinge, in dieser Reihenfolge:
- Was plMail ist, und die Entwurfsphilosophie, die ein Client übernehmen sollte.
- Wie es aussehen und sich anfühlen soll — das visuelle System, die Layoutregeln, die Bewegung, die Texte.
- Wie es sich verhält, und über welche API — JMAP, Authentifizierung, Push, Blobs, Suche.
Zum Installieren und Betreiben des Servers siehe README.md. Zur Entwicklung am Server siehe CONTRIBUTING.md. Dieses Dokument setzt voraus, dass der Server bereits irgendwo läuft.
plMail wird aktiv weiterentwickelt, und die Serverseite steht für die Bedürfnisse deines Clients vollständig zur Disposition. Dieses Dokument beschreibt, was es heute gibt. Es ist eine Momentaufnahme, kein festgeschriebener Vertrag, und einiges, was hier als nicht vorhanden aufgeführt ist, fehlt nur deshalb, weil es bisher niemand gebraucht hat.
Wenn du also gegen eine Wand läufst — eine fehlende JMAP-Methode, ein Endpunkt, der ein von dir benötigtes Feld nicht herausgibt, ein Konzept, das in der Datenbank lebt und nicht in der API, ein für Mobilgeräte falsch gesetztes Limit:
Frag nach. Bau nichts drumherum.
Den Endpunkt, die Methode, das Feld oder die Herstellererweiterung zu ergänzen ist ein völlig normaler Ausgang und meistens der richtige. Eine clientseitige Umgehung, die fehlendes Serververhalten nachbaut, ist fast immer die falsche Antwort: Sie dupliziert Logik, die an eine Stelle gehört, sie läuft von der Web-Oberfläche weg, und sie wird still und leise tragend.
Konkret: halt inne und frag nach, bevor du
- 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 zurückwandern kann;
- etwas so denormalisierst oder cachest, dass es kaputtginge, wenn der Server es später ordentlich täte.
Was dieses Dokument als nicht implementiert kennzeichnet — Email/queryChanges,
Anchor-Paging, 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.
Der Kalender war genau dafür das Beispiel, und er ist jetzt das Beispiel dafür, dass es
funktioniert. In diesem Abschnitt stand früher, es gebe keine JMAP-Kalender-API und ein using
mit einem Kalender-URN werde rundheraus abgelehnt. Es gibt sie: Calendar/get,
CalendarEvent/get, CalendarEvent/query und CalendarEvent/set, unter
urn:plmail:params:jmap:calendars, ausgewiesen in Capability::SUPPORTED und aus genau einem
Konto bedient. Siehe JMAP zu den ID-Räumen und den zwei Dingen, die
Menschen überraschen — eine CalendarEvent-ID ist die Serie und nicht eine datierte
Termininstanz, und CalendarEvent/query verlangt ein Zeitfenster.
Wenn du etwas baust, das Termine will, sag es — die Speicherung ist genau deshalb schon
JSCalendar, damit die API JSCalendar sein kann, und der Zuschnitt der Methoden (Calendar/get,
CalendarEvent/query, /changes gegen den bestehenden StateManager) steht fest. Zwei Dinge
solltest du wissen, bevor du fragst:
- JMAP for Calendars ist noch ein Entwurf der IETF; wenn das ausgeliefert wird, wird deshalb
ein Hersteller-URN ausgewiesen (
urn:plmail:params:jmap:calendars), dem Präzedenzfallurn:plmail:params:jmap:pushfolgend. Verdrahte den URN des Entwurfs nicht fest. - Lies
calendar_event.jscalendarnicht über irgendeinen Seitenkanal, und baue Serienregeln nicht clientseitig nach. Termininstanzen werden serverseitig bis zu einem begrenzten Horizont materialisiert, und das aus guten Gründen (ein unbegrenztesFREQ=DAILYhat keine letzte Instanz); ein Client, der Regeln selbst expandiert, wird an Zeitumstellungen und bei überschriebenen Instanzen von der Web-Oberfläche abweichen.
Und die zweite Hälfte dieses Versprechens gibt es jetzt. „Expandiere Serienregeln nicht
selbst" war eine Anweisung ohne günstigen Weg, ihr zu folgen: Eine eingeklappte
CalendarEvent/query benennt eine Serie, ohne zu sagen, an welchen Tagen sie liegt — einen Monat
zu zeichnen hieß also eine Abfrage pro Tag. Schick expandRecurrences: true, und dieselbe
Abfrage antwortet mit einem Eintrag je Termininstanz, nach Beginn sortiert, wobei
position/limit/total Instanzen zählen — eine Abfrage für den Monat. Jede Instanz-ID lautet
<eventId>_<recurrenceId>, z. B. 42_20260304T090000Z; behandle sie als opak, gib sie direkt
an CalendarEvent/get (die übliche #ids-Paarung funktioniert) und lies start und
recurrenceId vom Objekt statt aus der ID. Einmalige Termine behalten ihre schlichte Serien-ID,
und ohne das Argument oder mit false hat sich an der Antwort nichts geändert.
CalendarEvent/set nimmt keine Instanz-ID an und sagt das auch; seriesId am Objekt ist die ID,
über die du schreibst. Die vollständige Form, samt dem, was eine expandierte Abfrage verweigert
und warum, steht in JMAP.
Daraus folgt: Wenn du hier etwas Überraschendes liest, gleiche es mit dem Code ab, bevor du
darum herum entwirfst. src/Jmap/ ist die maßgebliche Quelle, und sie bewegt sich.
plMail ist ein selbstgehosteter Mail-Client. Er läuft auf einer Maschine, die der Nutzerin gehört — ein NAS, ein Heimserver, ein kleiner VPS —, verbindet sich mit den Postfächern, die sie ohnehin hat (IMAP, Gmail, Outlook/Microsoft 365), und synchronisiert jede Nachricht in eine lokale PostgreSQL-Datenbank.
Er ist nachdrücklich kein Mailserver. Er nimmt keine Mail aus der Außenwelt entgegen, hostet keine Domain und betreibt kein MX. Er ist die Client-Schicht: eine Oberfläche, ein Suchfeld, ein Satz Label über so viele Anbieter hinweg, wie die Nutzerin hat.
Drei Konsequenzen treiben fast jede clientseitige Entscheidung:
Der Server ist die maßgebliche Quelle, und er ist schnell. Die Mail liegt bereits in Postgres, indiziert, zu Konversationen gebündelt und volltextdurchsuchbar. Deine App sollte keine konkurrierende Sync-Engine gegen Gmail oder IMAP bauen — sie spricht mit plMail, und plMail spricht mit den Anbietern. Ein Client, der am Server vorbeigreift, bricht das Ein-Datenbank-Versprechen, auf dem das ganze Produkt steht.
Der Server ist der Server der Nutzerin. Er kann im heimischen LAN stehen, hinter Tailscale oder an einer langsamen ADSL-Leitung. Er kann kurz unerreichbar sein, wenn das NAS neu startet. Nimm an: schwankende, mitunter hohe Latenz, gelegentlich selbstsignierte Zertifikate oder solche von einer privaten CA, kein CDN, kein globales Anycast und ein einzelner PHP-Worker-Pool, den ein sich schlecht benehmender Client tatsächlich erschöpfen kann. Cache großzügig, polle selten, verfalle sanft, und geh nie in eine Endlosschleife.
Mehrere Konten sind der Normalfall, nicht der Randfall. Eine Nutzerin mit einem dienstlichen Gmail, einem privaten IMAP und einem Outlook-Konto ist die Zielgruppe. Der vereinigte Posteingang ist die Standardansicht. Jeder Bildschirm deiner App sollte zuerst für mehrere Konten entworfen sein und erst danach für eines.
Die Server-Codebasis ist ungewöhnlich meinungsstark, und die Meinungen sind es wert, übernommen zu werden, denn sie sind es, die das Produkt stimmig wirken lassen.
-
Gmail ist das Vokabular, nicht die Ästhetik. Label statt Ordner. Konversationen statt
Nachrichten. Ein einziges Suchfeld mit den Operatoren
from:/is:/has:. Wer von Gmail kommt, soll keine neuen Substantive lernen müssen. Aber das Aussehen ist plMails eigenes — weicher, ruhiger, mehr einstellbar. - Alles ist themebar, und das Theme gehört der Nutzerin. Farbe, Dichte, Eckenradius, Transluzenz, Hintergrund — alles von der Nutzerin gesetzt, alles serverseitig synchronisiert. Ein Client, der seine Palette fest verdrahtet, ist falsch. Siehe §2.
- Label sind das Konzept für die Nutzerin; Postfächer sind Installationstechnik. IMAP-Ordner existieren als Sync-Infrastruktur. Die Nutzerin sieht Label, und eine Nachricht kann mehrere tragen. Zeig keine Ordner.
- Nichts wird je endgültig gelöscht. „Löschen" heißt „in den Papierkorb verschieben". Der Server hat überhaupt keinen Pfad zum endgültigen Löschen — eine Zeile zu löschen verwürfe die lokale Kopie einer Mail, die der Anbieter weiterhin hält. Deine destruktive Oberfläche sollte „Papierkorb" sagen und rückgängig zu machen sein.
- Kommentare erklären das Warum, und deine Oberfläche sollte es auch. Der Servercode ist voll von „das sieht seltsam aus, weil X uns gebissen hat". Nimm diesen Geist mit in leere Zustände und Fehlermeldungen: Sag, was passiert ist und was die Nutzerin tun kann, nie bloß „Fehler".
- Zuerst lokal, dann der Anbieter. Aktionen wirken lokal und pflanzen sich dann nach außen zu Gmail/IMAP/Graph fort. Deine Oberfläche darf und soll optimistisch sein.
Bevor du einen Bildschirm erfindest, sieh dir an, wie die Web-Oberfläche es macht. Die lehrreichsten Dateien:
| Was | Wo |
|---|---|
| Design-Tokens, Utilities, Theme-Blöcke | assets/styles/app.css |
| Die App-Hülle (Viewport, PWA, Theme-Bootstrapping) | templates/_layout/app.html.twig |
| Der Aufbau einer Listenzeile | templates/_partials/_thread_row.html.twig |
| Verhalten Liste ⇄ Lesebereich auf Mobilgeräten | assets/controllers/mail/mail_pane_controller.js |
| Schublade / Icon-Leiste der Seitenleiste | assets/controllers/ui/sidebar_drawer_controller.js |
| Screenshots vom echten Ding | docs/screenshots/ |
plMail trennt Theme (die Palette) von Layout (die Behandlung). Jedes Theme lässt sich mit jedem Layout kombinieren. Über beiden liegen numerische Regler, die die Nutzerin einzeln überschreiben kann.
Theme — App\Domain\Enum\Theme\Theme
| Theme | Fläche | Schrift | Akzent | Dunkel? |
|---|---|---|---|---|
system |
folgt dem Betriebssystem | — | #2563eb |
folgt dem Betriebssystem |
light |
#ffffff |
#27272a |
#2563eb |
nein |
dark |
#111827 |
#f4f4f5 |
#3b82f6 |
ja |
nord |
#2e3440 |
#eceff4 |
#88c0d0 |
ja |
dusk |
#1e1b2e |
#ede9fe |
#a78bfa |
ja |
solar |
#fdf6e3 |
#586e75 |
#b58900 |
nein |
Layout — App\Domain\Enum\Theme\Layout
| Layout | Radius | Flächenunschärfe | Flächen-Alpha | Charakter |
|---|---|---|---|---|
flat (Standard) |
0.75rem | 0 | 1.0 | Die Rahmenelemente sitzen direkt auf dem Hintergrund; eine deckende Inhaltskarte. |
boxed |
1.0rem | 24px | 0.7 | Alles ist eine schwebende, transluzente Karte über dem Hintergrund. |
Ein Layout auszuwählen setzt die Regler unten vor; danach kann die Nutzerin jeden einzeln überschreiben.
Dichte — App\Domain\Enum\Theme\Density
| Dichte | Zeilenabstand (block) | Abstand |
|---|---|---|
comfortable (Standard) |
0.875rem | 0.75rem |
cosy |
0.625rem | 0.5rem |
compact |
0.375rem | 0.375rem |
Regler der Nutzerin — die maßgebliche Liste samt Begrenzungen steht in
Appearance:
| Feld | Typ | Bereich | Bedeutung |
|---|---|---|---|
accent |
hex | #rrggbb |
Akzentfarbe. Standard #2563eb. |
paneAlpha |
float | 0.15 – 1.0 | Deckkraft der Flächen des Gerüsts: Seitenleiste, Kopfleiste, Hauptbereich, Kalender. |
popoverAlpha |
float | 0.5 – 1.0 | Deckkraft der Flächen, die darüber schweben: Schreibfenster, Dialoge, Menüs, Hinweise. Die Untergrenze liegt absichtlich höher — wo sich beide überlagern, multiplizieren sich die Durchsichtigkeiten. Vorgabe 1.0. |
paneBlur |
int | 0 – 60 | Hintergrundunschärfe in px. |
radius |
float | 0.0 – 2.0 | Eckenradius in rem, nur für Flächen. |
scrimAlpha |
float | 0.0 – 0.7 | Schwarzer Schleier über einem eigenen Hintergrundbild. |
inkColor / inkMuted / inkFaint
|
hex|null | — | Überschreibungen der Textfarbe. |
mainTint / mainAlpha
|
hex|null / float|null | — | Tönung und Deckkraft speziell der Hauptinhaltsfläche. |
backgroundKind |
enum |
theme | preset | solid | custom
|
Woher der App-Hintergrund kommt. |
backgroundPreset / backgroundSolid / backgroundFile
|
— | — | Der gewählte Hintergrund. |
logoStyle |
enum, nur lesbar | eines aus logoStyles
|
Die Farbgebung, in der die „pl"-Marke erscheint. |
logoMotif |
enum, nur lesbar | eines aus logoMotifs
|
Welches Symbol das Logo ist: die pl-Marke (pl) oder eines von neun Motiven. |
logoPaint |
enum, nur lesbar |
original oder eines aus logoStyles
|
Worin dieses Symbol gemalt ist: sein eigener Entwurf oder eine Farbgebung. Für pl immer eine Farbgebung. |
Appearance::toArray() ist das Exportformat (versioniert, version: 1), applyArray() der
Import. Die Web-Oberfläche lässt Nutzerinnen das als Datei exportieren und importieren.
Das ist über JMAP erreichbar.
Appearance/getundAppearance/setliefern das Singleton-Objekt (Id"singleton", keinaccountId— es hängt an derUser-Entität), und die Appearance-Capability der Session veröffentlicht die Vokabulare und Wertebereiche:themes,logoStyles,logoMotifs,layouts,densities,backgroundKinds,backgroundPresets,unreadEmphases,fontFamilies,ranges.previewLines,ranges.fontScale,ranges.popoverAlpha. Modelliere dieselbe Form mit zwei Achsen aus Theme × Layout mit denselben semantischen Tokens und lies die Werte des Servers hinein. Zwei Dinge solltest du vor dem ersten Schreiben wissen: Booleans werden streng geprüft,"1"und"0"werden also abgelehnt statt umgewandelt; und die drei Dichten pro Oberfläche brauchen ein ausdrückliches JSON-nullfür „folge der globalen Dichte" — das ist eine andere Anweisung als ein weggelassener Schlüssel. Durchgesetzt wird die Regel „verdrahte keine Palette fest".
logoStyleist nur lesbar — und es ist die Marke, die die Nutzerin tatsächlich vor sich hat. Der Wert stammt aus der festen Menge, die die Session alslogoStylesveröffentlicht:"berry"(die Produktvorgabe),"product-blue","petrol-copper"und neunundzwanzig weitere. Ein Client, der sie auf eigene Grafiken abbildet, kann die Liste beim Discovery abholen und erkennt so, wann er einen Wert in der Hand hält, für den er nichts hat. Behandle einen unbekannten Wert als die Vorgabe und nicht als Fehler; die Menge wächst.Setzen lässt er sich nicht. Das ist weder ein Versehen noch eine Frage der Berechtigung: auf dem Server ist der Wert abgeleitet — aus dem Theme, daraus, ob die Nutzerin die Marke vom Theme gelöst hat, und erst dann aus einer gespeicherten Farbgebung. Jede der zweiunddreißig ist auch ein Theme-Name, und standardmäßig kleidet die Wahl eines Themes die Marke passend ein. Deshalb liest du hier die Marke so zurück, wie das Web sie in der Kopfleiste und im Favicon zeichnet, und nicht eine Datenbankspalte.
logoStylemit einem anderen Wert zu senden wird mitinvalidPropertiesabgelehnt; mit genau dem eben gelesenen Wert wird es angenommen und ignoriert, damit get → ein Feld ändern → set genauso funktioniert wie bei jeder anderen Eigenschaft. Um die Marke zu bewegen, setzetheme— die neue Farbgebung kommt in derupdated-Map desselben Aufrufs zurück. Die Marke vom Theme zu lösen ist heute eine reine Web-Einstellung.
logoMotifundlogoPaintsind das Logo, wie die Nutzerin es sieht — und beide sind nur lesbar. Im Web besteht das Logo aus zwei Entscheidungen: erst ein Symbol, dann seine Bemalung. Das Symbol ist die pl-Marke oder eines von neun Motiven, aus denlogoMotifsder Session —"pl","blue-horn","at-horn","love-letter","airmail","happy-mail","snail-mail","mailbox","pl-stamp","wax-seal". Die Bemalung ist"original", der eigene Entwurf des Motivs, oder eine auslogoStyles. Für"pl"ist sie immer eine Farbgebung, und immer der Wert, denlogoStylehat;"original"kommt nur zusammen mit einem der neun Motive vor.logoStylebehält daneben seine Bedeutung — die Farbgebung der pl-Marke, egal welches Symbol —, ein Client, der nur die Marke kennt, zeichnet also weiter die Marke. Behandle ein unbekanntes Motiv als"pl"und eine unbekannte Bemalung als"original"; beide Mengen wachsen.Wie ein Symbol in einer Bemalung aussieht, steht nicht auf der Leitung, und abschreiben solltest du es auch nicht. Jedes Symbol × jede Bemalung → die Farbe jedes seiner Teile ist eine einzige Tabelle, die
php bin/console app:branding:export-paintsim Schema vonlogo-paints.jsonausgibt: Ein Teil ist ein#rrggbb, einnullfür einen Teil, der nicht gezeichnet wird, oder sieben Verlaufsstopps. Erzeuge deine Grafiken aus dieser Ausgabe und erzeuge sie neu, wenn sie sich ändert. Wer ein Bild statt einer Tabelle braucht:/branding/icon/{motif}/{paint}.svgliefert das Symbol als seine abgerundete Kachel, ohne Sitzung.Setzen lässt sich keins von beiden: Ein anderer Wert wird mit
invalidPropertiesabgelehnt, ein Echo angenommen — genau wie beilogoStyle. Das Symbol wird in der Web-Oberfläche gewählt.
Der Radius gilt für Flächen, nicht für Bedienelemente. Modale, das Verfassen-Fenster, Dropdowns, Menüs und Toasts nehmen
--app-radius. Buttons, Eingabefelder, Chips und Listenzeilen behalten einen festen kleinen Radius — sie dürfen nicht auf 2rem-Ecken anwachsen. Diese Unterscheidung ist Absicht und wird leicht falsch gemacht.
Verweise nie auf rohe Palettenwerte. Bau deinen Client gegen denselben Satz semantischer Tokens,
den auch das CSS verwendet, damit ein Themewechsel alles auf einen Schlag neu auflöst. Die
kanonische Liste steht im @theme inline-Block von app.css:
| Token | Verwendung |
|---|---|
surface |
Hintergrund von Karten und Flächen. |
line |
Haarfeine Trenner (sehr geringes Alpha). |
raised / hover
|
Dezente erhabene Füllungen und Hover-Zustände. |
ink / ink-soft / ink-muted / ink-faint
|
Text, in vier abnehmenden Gewichtungen. |
accent / accent-strong / accent-soft / accent-ink
|
Der Akzent und seine Varianten. |
sunken |
Vertiefte Mulden (Eingabehintergründe, Codeblöcke). |
field / field-border
|
Formularelemente. |
danger / warning / success / info
|
Status. Jedes hat eine -soft-Hintergrundvariante. |
inverse / inverse-ink
|
Tooltips und invertierte Chips. |
Zusammengesetzte Flächen: pane (Karte mit Rand und Schatten), pane-flat (ohne Schatten),
popover (vollständig deckend — ein transluzentes Dropdown über einem Fotoraster ist
unlesbar), main-pane (die Inhaltskarte, beachtet mainTint/mainAlpha) und app-bg (der
Verlauf beziehungsweise das Bild im Hintergrund plus Schleier).
Gerenderte Mail-Inhalte übernehmen die Palette der App nicht. Mail kommt für einen weißen
Hintergrund verfasst an, ihr eine dunkle Fläche zu geben ergibt also schwarzen Text auf Schwarz.
Die mail-sheet-Utility der Web-Oberfläche deklariert die Palettenkanäle lokal neu, damit
alles darin — einschließlich deiner eigenen Rahmenelemente, falls du welche verschachtelst — zu
hellen Werten auflöst.
Im Web heißt das ein dauerhaft helles Sheet. Auf einem Telefon geht das nicht: Eine Mail-App, deren Lesebereich der eine Bildschirm ist, der nachts weiß bleibt, ist nicht akzeptabel, und Nutzerinnen werden das auch sagen.
Ein nativer Client sollte also dunkel rendern — aber nicht, indem er alles invertiert, denn das ist der Ansatz, der verlässlich kaputt aussieht. Fotos kommen als Negative heraus, Logos in den falschen Markenfarben, und eine Nachricht, die bereits eigene dunkle Stile mitbringt, invertiert doppelt zu etwas, das schlimmer ist als beide Extreme.
Wähl je Nachricht eine Strategie, ausgehend davon, was ihr HTML über sich selbst aussagt:
| Die Nachricht | Was zu tun ist |
|---|---|
| Bringt keine eigenen Farben mit — eine getippte Antwort, die meiste private Mail | Style sie neu in deiner dunklen Palette. Nichts wird invertiert, also kann nichts wie ein Negativ aussehen. Das ist das beste erreichbare Ergebnis. |
| Hat eine eigene Palette — Newsletter, alles Gestaltete |
Invertiere mit hue-rotate(180deg), und invertiere img, picture, video, svg und Elemente mit Hintergrundbild anschließend zurück. Diese zweite Regel ist die, die alle vergessen, und sie auszulassen ist es, was der Invertierung ihren Ruf eingebracht hat. |
Deklariert bereits prefers-color-scheme
|
Sag ihr, das Schema sei dunkel, und lass sie in Ruhe. Die Absenderin hat die Arbeit gemacht. |
| Alles davon, im hellen Erscheinungsbild | Rendere genau so, wie es gesendet wurde. |
Daraus folgt zweierlei. Biete überall dort einen Weg zurück zum Original an, wo du eine
Nachricht umgeformt hast — die Invertierung geht bei mancher Mail daneben, und wenn einem
gesagt wird, eine verhunzte Nachricht sei in Ordnung, ist das schlimmer, als zu sehen, dass sie
verhunzt ist. Und beachte, dass invert+hue-rotate eine Matrixnäherung ist und keine echte
HSL-Rotation; hin- und zurückgedrehte Farben kommen also leicht entsättigt zurück, und das ist
der Preis der Technik.
Was sich nicht geändert hat: Reich niemals das Theme der Nutzerin in den Nachrichtenrenderer weiter. Die Nachricht bekommt eine der obigen Behandlungen, nicht die Akzentfarbe, nicht das Flächen-Alpha und nicht das Hintergrundbild.
Desktop / Tablet (≥768px) — drei Bereiche:
┌──────────────────────────────────────────────┐
│ topbar: search, sync, account, settings │
├────────────┬─────────────────────────────────┤
│ sidebar │ list │ reading pane │
│ Compose ▸ │ (threads) │ (thread) │
│ Inbox 12 │ │ │
│ Starred │ │ │
│ Sent │ │ │
│ Labels… │ │ │
└────────────┴─────────────────────────────────┘
Die Seitenleiste klappt zu einer 56px breiten Icon-Leiste zusammen (Zustand bleibt erhalten; im Web wird er vor dem ersten Zeichnen angewandt, damit die breite Seitenleiste nie aufblitzt). Aktive und überfahrene Navigationszeilen verwenden eine Pille im Gmail-Stil, die links über den Rand hinausläuft und rechts mit vollem Radius abschließt.
Mobil (<768px) — die Seitenleiste wird zu einer einfahrenden Schublade über einem
Hintergrundschleier, und Liste und Lesebereich werden zu zwei gestapelten Flächen: Ein Tipp
auf eine Zeile ersetzt die Liste durch die Konversation, und Zurück führt zur Liste. Im Web wird
das mit history.pushState gemacht, sodass der Zurück-Knopf von Gerät oder Browser natürlich
funktioniert — ein nativer Client sollte das auf ein normales Push auf den Navigationsstapel
abbilden.
Das Verfassen ist mobil bildschirmfüllend; auf dem Desktop ist es ein angedocktes Fenster
unten rechts (fixed bottom-4 right-6), und mehrere können gleichzeitig offen sein.
Aus _thread_row.html.twig; eine Zeile zeigt:
- Beteiligte — alle, die in der Konversation geschrieben haben, älteste zuerst. Nicht die neueste Absenderin; das ließ jede Konversation, die man beantwortet hatte, so aussehen, als käme sie von einem selbst.
- Betreff, mit Rückfall auf ein übersetztes „(kein Betreff)".
- Ausschnitt — die ersten rund 100 Zeichen des Klartextkörpers der neuesten Nachricht, ohne Tags.
- Anzahl der Nachrichten, wenn > 1.
- Datum — Zeit der letzten Nachricht.
- Zustandsmerkmale: ungelesen (Schriftschnitt/Indikator), markiert, Büroklammer für Anhänge.
- Hover-Aktionen (Desktop): archivieren, in den Papierkorb, zurückstellen, gelesen/ungelesen markieren, Label.
Ungelesen und markiert stehen als data-unread / data-starred an der Zeile, sodass das Styling
am Zustand hängt statt an duplizierten Klassen. Mach das nach: eine Zeilenkomponente,
zustandsgesteuert.
Entwurfsregel (subtil, mach sie richtig): Eine Zeile öffnet die Verfassen-Oberfläche statt des Lesebereichs nur dann, wenn die Zeile der Entwurf ist — also eine Konversation, die eine einzelne Entwurfsnachricht enthält, oder eine nackte Entwurfszeile. Eine echte Konversation, die eine ungesendete Antwort trägt, öffnet weiterhin die Konversation, und dieser Entwurf wird von innerhalb des Lesebereichs bearbeitet. In der Entwurfsliste wird das überstimmt: Dort öffnet jede Zeile ihren Entwurf.
- Bewegung ist funktional, nicht dekorativ. Schublade fährt ein, Fläche wechselt, Toast kommt und geht. Keine Federphysik, kein Parallaxe-Effekt, keine Heldenanimationen auf Mail-Zeilen.
-
Beachte reduzierte Transparenz. Das CSS erzwingt
paneAlpha: 1,popoverAlpha: 1,paneBlur: 0,scrimAlpha: 0unterprefers-reduced-transparency: reduce. Mach dasselbe, und beachte auch reduzierte Bewegung. -
Sichere Bereiche. Die Web-App läuft mit
viewport-fit=coverund polstert perenv(safe-area-inset-*). Native Clients bekommen das geschenkt, dürfen aber weder das Verfassen-Dock noch die Werkzeugleiste unter dem Home-Indikator sitzen lassen. - Die Web-App hat keine Wischgesten auf Zeilen — Aktionen sind Buttons. Ein nativer Client sollte Wischen zum Archivieren und in den Papierkorb ergänzen, denn das ist das Plattform-Idiom; achte nur darauf, dass alles, was ein Wisch tut, auch über ein ausdrückliches Bedienelement erreichbar und rückgängig zu machen ist.
- Nichts scrollt die Seite seitwärts. Flächen scrollen ihren eigenen Überlauf. Werkzeugleisten, die nicht passen, scrollen horizontal mit ausgeblendeter Bildlaufleiste.
Die tatsächlichen Zeichenketten stehen in translations/. Das Register ist durchgehend ruhig, konkret und eher kleingeschrieben — schlichte Sätze, keine Ausrufezeichen, kein „Hoppla!". Fehler benennen die Ursache. Die Oberfläche wird auf Englisch und Deutsch ausgeliefert; wenn du Zeichenketten ergänzt, ergänze beide, und entwirf dafür, dass Deutsch etwa 30 % länger ist.
- Texteingaben werden auf kleinen Bildschirmen mit ≥16px gerendert (darunter zoomt iOS beim Fokussieren ungeachtet der Viewport-Einstellungen). Halte dich daran.
- Alles Anklickbare muss anklickbar aussehen und sich so verhalten, und alles, was mit dem Zeiger erreichbar ist, muss auch per Tastatur und Screenreader erreichbar sein.
- Bedienelemente, die nur aus einem Icon bestehen, tragen in der Web-Oberfläche
aria-labels. Setz native Barrierefreiheitsbeschriftungen. - Der Kontrast muss in allen sechs Themes halten, und genau dafür gibt es die semantischen Tokens.
plMail stellt unter /jmap JMAP bereit (RFC 8620 / RFC 8621). Das ist die API für
Drittanbieter- und native Clients. Sie ist die einzige stabile, dokumentierte, versionierte
Oberfläche.
Die eigenen Routen der Web-Oberfläche (/mail/*, /compose/*, /settings/*) liefern HTML und
Turbo Streams, kein JSON. Sie sind intern, unversioniert, CSRF-geschützt und werden sich ohne
Ankündigung ändern. Bau nicht dagegen.
Bekannte JMAP-Clients, die bereits gegen diesen Server funktionieren: ltt.rs (Bearer) und Sterna (Basic). Gegen einen von ihnen zu testen ist der schnellste Weg, deine eigene Implementierung auf Plausibilität zu prüfen.
Die JMAP-Firewall ist zustandslos und akzeptiert zwei Arten von Anmeldegeheimnissen.
App-Passwörter — heute verfügbar, und das, was du verwenden solltest.
Die Nutzerin legt eines unter Einstellungen → App-Passwörter an. Das Geheimnis wird genau einmal angezeigt und sieht so aus:
plmail_<64 hex chars>
Serverseitig wird nur ein SHA-256-Digest gespeichert, dazu ein 6 Zeichen langer Hinweis, damit
die Liste zeigen kann, welches welches ist. Token sind nutzerbezogen, nicht kontobezogen: Ein
Anmeldegeheimnis zählt jedes verbundene Mail-Konto auf. Sie lassen sich einzeln widerrufen.
lastUsedAt wird höchstens alle 5 Minuten aktualisiert, es ist also ein grobes Signal für
„kürzlich aktiv" und kein Prüfprotokoll.
Schick es auf eine der beiden Arten:
Authorization: Bearer plmail_abc123…Authorization: Basic base64(user@example.com:plmail_abc123…)Wenn du Basic schickst, wird der Benutzername gegen die Eigentümerin des Tokens geprüft — eine falsche Adresse wird mit einer klaren Meldung abgelehnt, statt dass stillschweigend als diejenige agiert wird, der das Token gehört.
JWT — verdrahtet, aber noch nicht ausstellbar. Die Firewall akzeptiert JWTs (für eine
künftige eigene App), und der Server erzeugt beim ersten Start ein Schlüsselpaar, aber es gibt
derzeit keinen Endpunkt, der eines ausstellt. Ein Bearer-Token, das mit plmail_ beginnt,
wird zum App-Passwort-Authenticator geleitet; alles andere fällt an JWT durch.
Heute baust du gegen App-Passwörter. Aber wenn du die eigene App schreibst, ist ein ordentlicher Anmelde-Endpunkt, der kurzlebige JWTs ausstellt, genau die Art Sache, um die man bitten sollte — die meiste Verkabelung ist schon da. Täusch keine Session-Schicht über App-Passwörtern vor, um darum herumzukommen; frag.
Form eines Fehlschlags — 401 mit application/problem+json und einer Aufforderung
WWW-Authenticate: Basic realm="plMail JMAP":
{ "type": "urn:ietf:params:jmap:error:unauthorized", "status": 401, "detail": "Invalid or revoked app password." }GET /.well-known/jmap (or GET /jmap/session)
Authorization: Bearer plmail_…Liefert das Session-Objekt. Alles Weitere wird daraus entdeckt — verdrahte die anderen Pfade
niemals fest, und lies apiUrl und Konsorten immer wieder von hier:
{
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 50000000,
"maxConcurrentUpload": 4,
"maxSizeRequestObject": 10000000,
"maxConcurrentRequests": 4,
"maxCallsInRequest": 32,
"maxObjectsInGet": 500,
"maxObjectsInSet": 500,
"collationAlgorithms": ["i;ascii-numeric", "i;ascii-casemap", "i;unicode-casemap"]
},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:submission": {},
"urn:plmail:params:jmap:push": {
"vapidPublicKey": "BN…",
"fcm": true,
"fcmConfig": {
"projectId": "plmail-abc123",
"applicationId": "1:1234567890:android:0123456789abcdef",
"apiKey": "AIza…",
"senderId": "1234567890"
}
}
},
"accounts": {
"7": {
"name": "me@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": null,
"maxMailboxDepth": null,
"maxSizeMailboxName": 255,
"maxSizeAttachmentsPerEmail": 50000000,
"emailQuerySortOptions": ["receivedAt", "from", "to", "subject", "size"],
"mayCreateTopLevelMailbox": true
},
"urn:ietf:params:jmap:submission": {
"maxDelayedSend": 2592000,
"submissionExtensions": { "FUTURERELEASE": ["HOLDFOR", "HOLDUNTIL"] }
}
}
}
},
"primaryAccounts": { "urn:ietf:params:jmap:mail": "7" },
"username": "me@example.com",
"apiUrl": "https://mail.example.com/jmap/api",
"downloadUrl": "https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://mail.example.com/jmap/upload/{accountId}",
"eventSourceUrl": "https://mail.example.com/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}",
"state": "…"
}Entscheidendes Modellierungsdetail: Je verbundenem Mail-Konto wird ein JMAP-Konto
offengelegt. Eine Nutzerin mit drei Postfächern sieht unter einer Anmeldung drei JMAP-Konten.
Der vereinigte Posteingang ist Sache des Clients — du führst eine Email/query je Konto aus
und führst die Ergebnisse selbst zusammen, sortiert nach receivedAt. Es gibt keine
serverseitige kontoübergreifende Abfrage.
urn:plmail:params:jmap:push ist eine Herstellererweiterung, die beschreibt, über welche
Push-Transporte diese Instanz tatsächlich zustellen kann; RFC 8620 definiert für nichts davon
einen standardisierten Platz.
| Schlüssel | Bedeutung |
|---|---|
vapidPublicKey |
Dein applicationServerKey für eine Web-Push-Subscription. Leer heißt, Web Push ist nicht konfiguriert — biete es dann nicht an. |
fcm |
Ob Firebase konfiguriert und eingeschaltet ist. Immer vorhanden, true oder false. |
fcmConfig |
Die Eingaben für Androids FirebaseOptions.Builder. Fehlt vollständig, wenn fcm false ist — nicht null. |
fcm ist immer vorhanden, damit du „dieser Server kann kein FCM" von „dieser Server ist älter als
FCM" unterscheiden kannst; die richtige Reaktion ist jeweils die entgegengesetzte. Bei fcmConfig
gilt aus dem entgegengesetzten Grund die entgegengesetzte Regel: Ein Null-Objekt verleitet dazu,
.projectId davon zu lesen und null zu bekommen, ein fehlender Schlüssel lässt sich nicht
dereferenzieren. Prüfe zuerst fcm.
Beachte: capabilities weist den Push-URN aus, aber die unterstützte using-Liste besteht nur
aus Core, Mail und Submission. Setz den Push-URN nicht in using.
Web Push setzt einen Push-Dienst voraus: etwas, dem die Endpunkt-URL gehört, das die
Verbindung zum Gerät hält und den verschlüsselten POST des Servers entgegennimmt. Browser bringen
so etwas mit. Eine native Android-App nicht, und Androids eigener Dienst ist FCM, der sein
eigenes Protokoll spricht — der WebPushSender kann nicht dorthin posten.
Ein Android-Client hat also drei Möglichkeiten:
-
UnifiedPush. Die Nutzerin installiert eine Distributor-App; die liefert einen Endpunkt
nach RFC 8030 und entschlüsselt die
aes128gcm-Nutzlast nach RFC 8291, die dieser Server ohnehin schon sendet. Überhaupt keine Serverkonfiguration. -
Firebase. Nichts, was die Nutzerin installieren müsste, und das, was die meisten
Android-Nutzerinnen erwarten. Unterstützt, seit der Server einen
FcmSenderhat; die Administration muss vorher die Zugangsdaten eines Firebase-Projekts einfügen — Google erfährt dann, dass eine Nachricht angekommen ist und wann. - Ein eingebetteter Distributor, bei dem die App den Socket selbst hält. Kostet je App einen Vordergrunddienst und eine dauerhafte Benachrichtigung.
Firebase gegen eine selbstgehostete Instanz initialisieren. Die übliche Android-Anordnung —
eine google-services.json, die zur Bauzeit verarbeitet wird — kann hier nicht funktionieren: Ein
APK bedient jede Installation, und jede Installation hat ihr eigenes Firebase-Projekt. Also
veröffentlicht der Server stattdessen die vier öffentlichen Werte als fcmConfig oben, und du
baust FirebaseOptions zur Laufzeit daraus, nachdem du die Session geholt hast:
val options = FirebaseOptions.Builder()
.setProjectId(config.projectId)
.setApplicationId(config.applicationId)
.setApiKey(config.apiKey)
.setGcmSenderId(config.senderId)
.build()Alle vier stecken im APK jeder Firebase-App und sind ihrer Natur nach öffentlich; der
Dienstkonto-Schlüssel, der tatsächlich senden kann, verlässt den Server nie. Hat die Instanz
mehrere Android-Pakete registriert, wird de.plmail.google veröffentlicht, sofern vorhanden,
sonst der erste registrierte Client.
Für (1) kann dieses Repository auch gleich den Push-Dienst liefern, damit Selbsthostende keinen suchen müssen:
docker compose --profile push up -d ntfyDas ist die ganze Einrichtung. Es ist standardmäßig aus, fügt keinen plMail-Code hinzu und
braucht keinerlei eigene Konfiguration: Die Endpunkt-URL wird aus dem beim ersten Start gesetzten
SERVER_NAME abgeleitet, denn der Host, den die Telefone ohnehin schon erreichen, ist das
Einzige, was sie sein muss. Überschreib NTFY_BASE_URL, wenn Push woanders leben soll.
Die abgeleitete URL ist http://$SERVER_NAME:8090. Zwei Konsequenzen, die man kennen sollte. Sie
lässt sich nicht so hinter dem eigenen Caddy der App an einem Pfad einfalten, wie der
Mercure-Hub unter /.well-known/mercure liegt — ntfy verweigert beim Start eine base-url mit
Pfad —, deshalb bekommt sie einen eigenen Port. Und die Endpunkt-URL ist selbst das Geheimnis;
zum offenen Internet hin willst du also TLS davor und NTFY_BASE_URL auf die https-Adresse
gesetzt, während im LAN oder über Tailscale der Standard so genügt, wie er ist.
Sie ist in jeden ausgegebenen Endpunkt eingebacken, sie später zu ändern zwingt also jedes Gerät zur Neuregistrierung.
Auf einer LAN- oder Tailscale-Adresse musst du dem Server außerdem sagen, dass der Endpunkt so
gemeint ist: Push-Endpunkte auf einer privaten Adresse werden abgewiesen, solange ihr Host nicht in
PUSH_ALLOWED_HOSTS steht, denn der Server schickt bei jeder Änderung einen POST dorthin. Setz die
Variable auf denselben Host wie SERVER_NAME.
Nutzlasten werden zum geräteeigenen Schlüssel verschlüsselt, bevor sie dort ankommen; der Push-Dienst kann Mail also nicht lesen, welchen du auch nimmst. Er erfährt aber, wann Mail ankommt, und das ist das Argument dafür, einen eigenen zu betreiben statt eines öffentlichen.
POST /jmap/api
Content-Type: application/json
Authorization: Bearer plmail_…{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/query", { "accountId": "7", "filter": { "inMailbox": "42" }, "sort": [{ "property": "receivedAt", "isAscending": false }], "limit": 50 }, "q0"],
["Email/get", { "accountId": "7", "#ids": { "resultOf": "q0", "name": "Email/query", "path": "/ids" }, "properties": ["id","threadId","subject","from","receivedAt","preview","keywords","hasAttachment","mailboxIds"] }, "g0"]
]
}Rückverweise (#ids) werden unterstützt und sind der vorgesehene Weg, Query und Get in einem
Roundtrip zu paaren — über eine langsame Heimleitung wichtig.
Zwei Argumentdetails, deren Nachlesen billiger ist als ihre Fehlersuche:
-
accountIdmuss eine JSON-Zeichenkette sein. Eine ganze Zahl wird mitinvalidArgumentsabgelehnt, nicht umgewandelt. -
Email/getliefertlistin Repository-Reihenfolge, nicht in der, die du angefragt hast, und berechnetnotFoundüber die Differenz. Wenn du es mitEmail/querygepaart hast, musst du das Ergebnis selbst gegen dieidsder Abfrage neu sortieren, sonst kommt deine Liste nach Datenbank-ID sortiert an.
Fehler auf Anfrageebene kommen als application/problem+json mit Status 400 und einem type von
urn:ietf:params:jmap:error:notJSON / notRequest / unknownCapability zurück.
Alles, was in src/Jmap/Method/ registriert ist:
| Methode | Anmerkungen |
|---|---|
Core/echo |
|
PushSubscription/get / PushSubscription/set
|
Keine accountId; je Nutzerin. |
Mailbox/get / Mailbox/query / Mailbox/changes / Mailbox/set
|
|
Email/get / Email/query / Email/changes / Email/set
|
|
Thread/get / Thread/changes
|
/get trägt drei plMail-Erweiterungen: snoozedUntil, category, isNew. |
Thread/set |
plMail-Erweiterung. Zwei Eigenschaften, snoozedUntil und isNew — 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, und expandRecurrences: true lässt sie je Termininstanz antworten. |
EmailSubmission/get / EmailSubmission/set / EmailSubmission/changes
|
|
Identity/get / Identity/set
|
Heute nicht implementiert. Nichts davon ist ein bewusster Ausschluss — es wurde bisher nur nicht gebraucht. Wenn dein Client etwas davon will, frag danach, statt darum herum zu konstruieren (siehe §0):
-
Email/queryChangesundMailbox/queryChangesgibt es nicht.Email/queryliefertcanCalculateChanges: false. Um eine Liste aufzufrischen, führst du die Abfrage erneut aus. NimmEmail/changesfür das Delta auf Objektebene und frag für die Sortierung neu ab. -
Anchor-basiertes Paging wird nicht unterstützt.
anchorlöstunsupportedFilteraus; nimmposition+limit. Negative Positionen (Verankern vom Ende her) werden vonEmail/queryabgelehnt;Mailbox/querynimmt sie an und verankert vom Ende. -
Email/querylieferttotal, außer du schickstcalculateTotal: false;Mailbox/querynur mitcalculateTotal: true. Das Zählen ist eine zweite Abfrage, ein Pager ohne Anzeige der Anzahl sollte alsofalseschicken. -
VacationResponse/*undBlob/copyfehlen.SearchSnippet/getnicht — es stand hier als fehlend, währendSearchSnippetGetMethodbereits im Baum lag. -
Kontakte sind nur Autovervollständigung.
Contact/autocomplete, unterurn:plmail:params:jmap:contacts, liefert gereihte Empfängervorschläge; es gibt keinContact/get,/queryoder/set— das Adressbuch wird aus Mail-Headern geerntet und ist nicht beschreibbar. Kalender werden bedient, unterurn:plmail:params:jmap:calendars; es gibt keinCalendar/setund kein/changesauf beiden Typen, weil Kalender keine Änderungen berechnen können — siehe JMAP.
1. Eine JMAP-Mailbox ist ein plMail-Label-Binding, kein IMAP-Ordner.
Label sind nutzerbezogen und reichen über Konten hinweg; ein LabelBinding ist die kontobezogene
Ausprägung eines Labels, und das ist es, was innerhalb eines JMAP-Kontos eine stabile Identität
hat. Also:
-
Mailbox.id= Binding-ID. -
Mailbox.labelId= die nutzerbezogene Label-ID, die dieses Binding materialisiert. Eine plMail-Erweiterung, nicht RFC 8621. Binding-IDs sind notwendigerweise kontobezogen, ein aus drei Konten erreichbares Label sind also drei Mailboxes mit drei zusammenhanglosen IDs und nichts, was sie verbände. Genau das erlaubt es einem Client, sie zu einer einzigen Seitenleistenzeile zusammenzuklappen — übernameabzugleichen bricht in dem Moment, in dem das Label in einem Konto umbenannt wird. Es ist keine ID, die du aninMailboxoderEmail/setübergeben kannst; die nehmen immer Binding-IDs. -
Mailbox.name= der Blatt-Name (JMAP modelliert Hierarchie überparentId, also"Invoices", nicht"Work/Invoices"). -
Mailbox.parentId= die Binding-ID des Elternteils, odernull, wenn das Elternteil in diesem Konto kein Binding hat (damit ein Kind nie auf eine unauflösbare ID zeigt). - Rollen werden aus plMails
LabelRoleabgebildet:inbox,sent,drafts,trash,junk(plMailsSpam),archive, dazuflagged,importantundall. Eine nicht abgebildete Rolle verfällt zunull— die Mailbox erscheint trotzdem. -
myRights: Systemlabel (role !== null) lassen sich weder umbenennen noch löschen; eigene Label sind vollständig veränderbar. Alles andere ist erlaubt. -
isSubscribedspiegelt den Sichtbarkeitsschalter des Labels. Beachte: Archiv wird standardmäßig verborgen angelegt und erscheint erst, wenn die Nutzerin es sichtbar schaltet.
Die Reihenfolge der Systemlabel in der Seitenleiste liegt fest: Posteingang 0, Gesendet 10, Entwürfe 20, Spam 30, Papierkorb 40, Archiv 50. Eigene Label sortieren danach, alphabetisch.
2. Email.mailboxIds stammt aus dem Label-Join je Nachricht, übersetzt in den Raum der
Binding-IDs.
Nicht aus der Vereinigung auf Konversationsebene — die zu lesen meldete für jede Nachricht der
Konversation eine Mailbox. Standardform einer JMAP-Map ({"42": true}), und {}, wenn leer
(niemals []).
Der Join speichert nutzerbezogene Label-IDs, veröffentlicht werden hier aber Binding-IDs,
damit sie zu Mailbox.id passen und sich unmittelbar an inMailbox und Email/set
zurückgeben lassen. Ein ID-Raum durchgehend — es gibt keinen Fall, in dem du übersetzen
musst. Ein Label, für das das Konto kein Binding hat, wird weggelassen statt als ID
veröffentlicht, die du nicht auflösen könntest.
Bis Mitte 2026 gab diese Eigenschaft unübersetzte Label-IDs aus. Weil beide Autoincrement-Ganzzahlen aus verschiedenen Tabellen sind, sahen die falschen IDs meist gültig aus und benannten irgendeine unbeteiligte Mailbox; das Symptom war also eine plausible falsche Antwort und kein Fehler — und auf einer Installation mit einem Konto war es unsichtbar, weil die beiden Sequenzen dort meist gleichlaufen. Wenn du das hier gegen einen älteren Server liest, ist es das, was du siehst.
3. Bodys sind synthetische Parts.
plMail speichert einen flachgeklopften Body (bodyText / bodyHtmlSafe), keinen MIME-Baum.
Jede Email veröffentlicht deshalb höchstens zwei Body-Parts mit den festen partIds "text"
und "html". Sie sind je Nachricht stabil, was alles ist, was fetchTextBodyValues /
fetchHTMLBodyValues brauchen. Behandle partId trotzdem als opak, wie die Spezifikation es
verlangt.
Achte auf die Groß-/Kleinschreibung: fetchHTMLBodyValues, nicht fetchHtmlBodyValues. Das
ist die Schreibweise aus RFC 8621 und das, was der Server liest. Ein nicht erkanntes Argument ist
schlicht abwesend, falsch geschrieben bekommst du also leere bodyValues und überhaupt keinen
Fehler.
Das veröffentlichte HTML ist immer die bereinigte Fassung, nie die rohe Spalte — dieser Body wird direkt an Drittanbieter-Clients gereicht, die ihn rendern.
preview ist der Klartextkörper, mit zusammengefallenen Leerräumen, gedeckelt auf 256 Zeichen.
4. Keywords sind teils Spalten, teils Flags.
| Keyword | Gestützt auf |
|---|---|
$seen |
Zeitstempelspalte seen_at
|
$flagged |
Zeitstempelspalte starred_at
|
$draft |
das IMAP-JSON-Array flags
|
$answered |
das IMAP-JSON-Array flags
|
Jedes andere Keyword wird abgelehnt, mit unsupportedFilter, wenn danach gefiltert wird.
Erfinde keine eigenen Keywords für deinen eigenen Zustand; sie werden nicht zurückwandern.
Die Form von Adressen wird an der Grenze übersetzt: plMail speichert {name, address}, JMAP gibt
{name, email} aus. messageId / inReplyTo / references werden als nackte IDs ohne spitze
Klammern ausgegeben.
plMail markiert eine Konversation als neu, bis ihre Zeile der Nutzerin tatsächlich vorgelegt wurde — und danach höchstens noch 24 Stunden, was auch geschieht. Daraus zeichnet das Web seine „Neu"-Abzeichen, seine Kategorie-Tabs und seine Punkte in der Seitenleiste, und es ist bewusst nicht dieselbe Achse wie ungelesen:
neu = der Nutzerin nie angezeigt UND innerhalb von
MessageThread::NEW_WINDOW(PT24H) eingetroffen
Eine Konversation, die du am Laptop gelesen hast, ist für einen Client, der ihre Zeile nie
gezeichnet hat, weiterhin neu; und den Marker zurückzuziehen markiert nichts als gelesen. Die
beiden dürfen sich widersprechen — das ist das Feature, nicht ein Fehler darin. Siehe
MessageThread::isNewAt().
Lesen. Thread/get liefert isNew (Boolean) auf jedem Thread, immer vorhanden. Das Zeitfenster
wird serverseitig gegen eine Uhrzeitablesung pro Antwort angewendet, zwei Threads einer Antwort
können die Grenze also nicht unterschiedlich sehen. Bau die 24 Stunden nicht im Client nach: das
wäre eine zweite Kopie von NEW_WINDOW, die auseinanderläuft, sobald jemand den Wert ändert.
Zurückziehen. Thread/set nimmt isNew: false an und sonst nichts — true wird mit
invalidProperties abgelehnt. Sende es für die Zeilen, die du der Nutzerin wirklich gezeigt hast,
nachdem du sie gezeigt hast. Es ist idempotent: eine Wiederholung verschiebt den gespeicherten
Zeitstempel nicht, der Eintrag sagt also weiterhin, wann die Zeile zuerst angezeigt wurde — du
darfst es gefahrlos bei jedem Zeichnen für jede Zeile senden.
Warum das wichtiger ist, als es aussieht. Vorher öffnete sich ein Postfach, das vollständig am Telefon aufgeräumt worden war, im Browser mit jeder Konversation des letzten Tages noch immer als „Neu" markiert und allen fünf Kategorie-Tabs noch immer bepunktet, weil nur das Web einen Marker zurückziehen konnte. Wenn dein Client Nachrichtenlisten zeichnet, melde die Anzeigen zurück — sonst lässt du die anderen Clients der Nutzerin falsch dastehen.
Das Zurückziehen wird bewusst nicht als Thread-Statusänderung gemeldet. Ein Client, der eine
Seite Post zeichnet, würde sonst dutzende Statusänderungen an jedes andere Gerät der Nutzerin
schicken, für eine Spalte, von der keines davon dringend erfahren muss; das nächste gewöhnliche
Thread/get trägt den neuen Wert.
Kompiliert vom EmailFilterCompiler. Alles, was
nicht verstanden wird, löst unsupportedFilter aus, statt stillschweigend ignoriert zu werden
— ein leise verworfener Filter liefert zu viele E-Mails zurück, und der Client kann es nicht
merken.
| Bedingung | Verhalten |
|---|---|
inMailbox |
Mailbox-(Binding-)ID. |
inMailboxOtherThan |
Nicht leeres Array von Binding-IDs. |
before / after
|
UTCDate gegen received_at (< und >=). |
minSize / maxSize
|
>= / < auf die Größe in Byte. |
hasKeyword / notKeyword
|
Nur die vier Keywords von oben. |
hasAttachment |
Boolescher Wert. |
text |
Echte Volltextsuche — Postgres tsvector + websearch_to_tsquery('english'). Gestemmt, gewichtet, kein Teilstring-Scan. |
body / subject / from
|
ILIKE auf Teilzeichenketten. from deckt Adresse und Anzeigename ab. |
to / cc / bcc
|
Teilzeichenkette über dem serialisierten JSON-Adressarray (trifft Name oder Adresse). |
filename |
Teilzeichenkette über Dateinamen von Anhängen. Inline-Parts haben leere Dateinamen und treffen nie. |
listId |
Teilzeichenkette über den kanonisierten list-id-Header. |
AND / OR / NOT als FilterOperator lassen sich beliebig verschachteln. Beachte, dass NOT
als NOT (a OR b …) implementiert ist.
Der EmailFilterCompiler versteht außerdem hasLabel / notLabel, die nutzerbezogene
Label-IDs nehmen statt Mailbox-(Binding-)IDs. Die gibt es für Mail-Regeln, die keinen Grund
haben, vom JMAP-ID-Raum zu wissen. Sie gehören nicht zum Filtervokabular für Clients — nimm
inMailbox.
Sortierung: receivedAt, from, to, subject, size. Limit: gedeckelt bei 500
(null oder größer wird 500). collapseThreads wird unterstützt.
Die Volltext-Konfigurationszeichenkette ('english') muss zu der passen, mit der die Spalte
erzeugt wurde — eine Abweichung liefert stillschweigend nichts zurück, weil die gestemmten Token
nie zusammenfallen. Versuch einfach nicht, das zu umgehen.
Dein Suchfeld sollte dieselben Operatoren im Gmail-Stil annehmen wie die Web-Oberfläche und sie
in JMAP-Filterbedingungen übersetzen. Aus dem
SearchQueryParser:
| Eingetippt | Bedeutet |
|---|---|
from:alice |
from |
to:bob |
to |
subject:invoice |
subject |
has:attachment |
hasAttachment: true |
is:unread / is:read
|
notKeyword: "$seen" / hasKeyword: "$seen"
|
is:starred |
hasKeyword: "$flagged" |
in:inbox|sent|drafts|trash|archive|junk |
inMailbox der Mailbox dieser Rolle |
after:2024-01-01 / before:2024-12-31
|
after / before
|
| alles Übrige | freier Text → text
|
Zeichenketten in Anführungszeichen bleiben zusammen. Unbekannte Operatoren fallen zu freiem Text durch, statt einen Fehler auszulösen — mach diese Nachsicht nach.
Legt Entwürfe an, aktualisiert Keywords und mailboxIds, und „zerstört".
-
destroyist ein Verschieben in den Papierkorb, kein Löschen der Zeile. Es gibt im ganzen Produkt keinen Pfad zum endgültigen Löschen. Stell es in deiner Oberfläche als Papierkorb dar. - Jede Änderung an Mailbox oder Keyword geht durch denselben Propagator, den auch die Web-Oberfläche verwendet; eine von deinem Client gemachte Änderung erreicht Gmail / IMAP / Graph also genauso wie eine im Browser gemachte. Aus deiner App zu archivieren archiviert in Gmail.
-
ifInStatewird beachtet — nimm es zur Konflikterkennung bei Stapelmutationen. - Das Anlegen von Entwürfen geht durch denselben Draft-Writer, den auch der Editor verwendet.
-
attachmentsfunktioniert beiupdategenauso wie beicreateund ist ein ganzer Wert. Häng eine Datei an einen bereits bestehenden Entwurf, indem du die vollständige Liste schickst, die am Ende dranhängen soll — diep--BlobIDs, die dirEmail/getfür die behaltenen Teile gegeben hat, plus dieu--BlobID des gerade Hochgeladenen. Weggelassene Teile werden entfernt und ihre Bytes gelöscht. Ein perp--BlobID erneut aufgeführter Teil behält seine ID und wird nicht neu hochgeladen; gibst du dabei einen anderennamean, wird die Datei umbenannt. Du musst den Entwurf nicht neu anlegen. -
Eine BlobID, die sich nicht auflösen lässt, bringt das ganze Update zu Fall. Du bekommst
notUpdated[id].type = "invalidProperties", und nichts aus diesem Patch wird angewendet — weder die Anhänge noch der Betreff daneben. Lade neu hoch und schick den ganzen Patch erneut.
Semantische Erinnerung: „archiviert" heißt in plMails Domänenmodell trägt kein Posteingangs-Label. Zum Archivieren entfernst du die Mailbox-ID des Posteingangs. Das Archiv-Label selbst ist Buchführung über den IMAP-Ort für reine IMAP-Konten und standardmäßig verborgen.
Das Senden wird auf demselben Message-Bus eingereiht, den auch der Web-Editor verwendet. Diese
Kette führt den gesamten Übergang vom Entwurf zur gesendeten Nachricht selbst durch (fügt
„Gesendet" hinzu, entfernt „Entwürfe", löscht \Draft, setzt sentAt, richtet das Postfach neu
aus), sodass auch ein Client, der onSuccessUpdateEmail weglässt, am Ende richtig dasteht.
["EmailSubmission/set", {
"accountId": "7",
"create": { "s1": { "emailId": "#draft1", "identityId": "3" } },
"onSuccessUpdateEmail": { "#s1": { "mailboxIds/42": null, "mailboxIds/17": true } }
}, "c0"]Was du wissen solltest:
-
Eine Submission hat keine eigene Tabelle — ihre ID ist die Email-ID. plMail sendet jeden Entwurf höchstens einmal, die Zuordnung bleibt also eineindeutig. Abrufbar ist sie trotzdem vollständig: siehe Eine Submission zurücklesen weiter unten.
-
undoStatuswird als"pending"gemeldet: Der Versand steht wirklich in der Warteschlange und hat, wenn der Aufruf zurückkehrt, noch nicht stattgefunden. -
Die Kulanzfrist der Web-Oberfläche zum Rückgängigmachen wird bei JMAP-Submissions bewusst NICHT angewandt. Ein JMAP-Client hat darum gebeten, jetzt zu senden. Wenn du in deiner App ein Rückgängig-Fenster willst, bau es clientseitig, indem du den Submission-Aufruf verzögerst.
-
Terminiertes Senden wird unterstützt, und zwar in der Schreibweise der Spezifikation: RFC 8621 §7 führt die SMTP-Erweiterung FUTURERELEASE (RFC 4865) als Envelope-Parameter mit, statt eine eigene Eigenschaft zu definieren. Setze
HOLDFOR(Sekunden) oderHOLDUNTIL(einen Zeitpunkt) — nicht beides — inenvelope.mailFrom.parametersund lies die echte Freigabezeit ansendAtab:["EmailSubmission/set", { "accountId": "7", "create": { "s1": { "emailId": "#draft1", "envelope": { "mailFrom": { "parameters": { "HOLDFOR": "3600" } } } } } }, "c0"]
maxDelayedSendin den Submission-Capabilities des Kontos ist die Obergrenze — 30 Tage — und sie wird durchgesetzt, nicht gekappt: Eine längere Haltezeit wird abgelehnt statt verkürzt. Eine bereits verstrichene Haltezeit sendet sofort. Der Rest des Envelopes wird geprüft, aber nicht angewandt: plMail sendet an die auf der Email gespeicherten Empfänger und mit der dort gespeicherten Absenderadresse, eine abweichendemailFrom.emailoder ein abweichendesrcptTowird also abgelehnt statt stillschweigend ignoriert. -
Abbrechen vor der Freigabe: Setze
undoStatusder Submission per Update auf"canceled". Das ist verlässlich, solange die Nachricht gehalten wird, ein Wettlauf, sobald sie ohne Haltezeit eingereiht wurde, und wird mitcannotUnsendabgelehnt, sobald sie gesendet ist. Ein Abbruch auf einer Email, die du nie eingereicht hast, wird mitnotFoundabgelehnt — es gibt keine Submission zum Abbrechen, und früher hinterließ ein solcher Aufruf eine Markierung, die den nächsten Versand der Nutzerin verschluckte. -
Fehler:
invalidProperties(fehlende/unbekannteemailId, fehlerhafter Envelope, zu lange Haltezeit),forbiddenFrom(eineidentityId, unter der dieses Konto nicht senden darf),invalidRecipients,alreadyExists(bereits gesendet),noRecipients,cannotUnsend,notFound(nichts abzubrechen).
EmailSubmission/get antwortet ab dem Moment der Annahme, und zwar in allen drei Zuständen der
Spezifikation:
| Zustand | undoStatus |
sendAt |
|---|---|---|
| Eingereiht oder gehalten, noch nicht raus | "pending" |
wann sie fällig ist — die echte Freigabezeit |
| Vor dem Versand abgebrochen | "canceled" |
wann sie hinausgegangen wäre |
| Gesendet | "final" |
wann sie tatsächlich hinausging |
Eine Email, die nie eingereicht wurde, ist notFound. Das ist der einzige notFound-Fall: Er ist
das Fehlen einer Submission, nicht einer ihrer Zustände.
Das hat sich geändert, und wenn du gegen das alte Verhalten gebaut hast, kannst du jetzt Code
löschen. Eine gehaltene Submission antwortete früher die ganze Haltezeit über mit notFound und
tauchte dann als "final" auf — die Freigabezeit aus der Create-Antwort war also die einzige
Kopie, die existierte. Ging diese Antwort verloren, war der Termin nicht mehr erfahrbar. Clients
mussten eine eigene, gerätelokale Liste terminierter Sendungen führen, und ein Telefon und ein
Laptop im selben Konto konnten sich nicht darüber einigen, wann eine Nachricht hinausgeht. Tu das
nicht mehr: sendAt aus EmailSubmission/get ist maßgeblich und gilt für jedes Gerät.
Praktisch heißt das:
-
Frage die Submission ab, nicht die Email, wenn es um den Versand geht.
EmailSubmission/changesmeldet alle drei Übergänge — das Einreichen alscreated, einen angenommenen Abbruch alsupdatedund das tatsächliche Hinausgehen alsupdated—, eine Liste terminierter Sendungen lässt sich also allein aus dem Änderungsprotokoll aktuell halten. -
sendAteiner"pending"-Submission ist eine Zusage über die Warteschlange, keine Garantie auf die Sekunde. Es ist der Zeitpunkt, ab dem der Worker starten darf, und eine ausgelastete Installation startet später. Zeig es als Uhrzeit, nicht als Countdown auf null. - Eine Submission, die beim Ausrollen dieser Funktion gerade gehalten wurde, behält ihr altes
Verhalten —
notFound, bis sie gesendet ist —, weil ihre Freigabezeit nur je im Warteschlangeneintrag stand. Davon gibt es höchstens eine Haltezeit lang welche.
Identitäten kommen aus derselben Liste, die auch das Von-Auswahlfeld des Web-Editors zeigt —
die sendefähigen Aliase des Kontos, das primäre zuerst. Ein Konto ohne Alias-Zeilen ergibt eine
synthetische Identität für die Kontoadresse selbst. Lass immer die Nutzerin wählen, und
voreingestellt ist das primäre: Die identityId einer Submission entscheidet über die
Absenderadresse, mit der die Nachricht tatsächlich hinausgeht, und eine, die keine Identität
dieses Kontos ist, wird mit forbiddenFrom abgelehnt, statt auf die Kontoadresse
zurückzufallen.
Upload — POST {uploadUrl} mit rohen Bytes und einem Content-Type:
{ "accountId": "7", "blobId": "u-91", "type": "image/png", "size": 40213 }Höchstens 50 MB (passend zu maxSizeUpload); größer ergibt tooLarge / 413. Der angegebene Typ
wird als Metadatum gespeichert und zurückgegeben, genau wie die Spezifikation es verlangt —
nichts wird geparst oder geglaubt. Uploads sind vorgemerkt: Ungenutzte räumt ein geplanter
app:prune:blobs-Job weg, lade also nahe an dem Zeitpunkt hoch, an dem du den Blob referenzierst.
Download — GET {downloadUrl} mit ausgefüllten {accountId}, {blobId}, {name}.
blobId hat einen Namensraum und ist opak: m-<id> (die RFC822-Quelle einer ganzen Nachricht),
p-<id> (ein Anhangs-Part), u-<id> (ein vorgemerkter Upload). Zerleg sie nicht — den
Namensraum gibt es gerade deshalb, weil die zugrunde liegenden Tabellen unabhängige
Autoincrement-IDs haben.
Sicherheitsverhalten, um das du herum entwerfen musst: Der Query-Parameter accept wird
ignoriert (ihn zu beachten ließe eine Aufruferin HTML als Bild umetikettieren).
X-Content-Type-Options: nosniff wird immer gesetzt, und nur image/* wird inline
ausgeliefert — alles andere kommt mit einer Attachment-Disposition zurück. Das wiegt hier
schwerer als in der Web-Oberfläche, weil ein JMAP-Client die URL womöglich direkt an eine Webview
weiterreicht. Bau keinen Betrachter, der inline gerendertes Beliebiges voraussetzt.
Das Segment {name} dient nur dem Dateinamen beim Download und wird nie für das Nachschlagen
geglaubt.
Drei Mechanismen, absteigend danach, was du bevorzugen solltest.
1. PushSubscription — die richtige Antwort für Zustellung im Hintergrund.
Zwei Transporte hinter einem Objekt. Ein Create mit url und keys ist eine
Web-Push-Subscription; ein Create mit fcmToken eine Firebase-Subscription. fcmToken
ist eine plMail-Erweiterung des Objekts aus RFC 8620; alles andere — deviceClientId, types,
expires, der Handshake — ist identisch.
Die beiden Formen schließen einander aus. Ein Create mit fcmToken und url (oder keys)
wird mit invalidProperties abgelehnt und benennt den Konflikt, statt dass eines für dich
ausgewählt wird. Ein Create mit fcmToken auf einer Instanz, auf der FCM nicht konfiguriert oder
abgeschaltet ist, wird mit forbidden abgelehnt — prüf zuerst die Capability; das hier ist nur
das Auffangnetz.
PushSubscription/get meldet, welche Art du bekommen hast, als schreibgeschütztes transport mit
"webpush" oder "fcm". Du brauchst das, weil deviceClientId je Gerät stabil ist und ein
erneutes Registrieren die Zeile ersetzt: Ein Telefon, das von einem UnifiedPush-Distributor auf
Firebase gewechselt ist, hat eine Subscription, nicht zwei. Weder keys noch fcmToken werden je
zurückgegeben — beides ist die Adresse eines Geräts, und sie zurückzuspiegeln hieße, dass jede
Person, die eine Antwort lesen kann, dorthin pushen könnte. url ist bei einer
FCM-Subscription null.
Ein FCM-Token zu rotieren ist ein PushSubscription/set-update mit fcmToken auf einer
bestehenden FCM-Subscription — die einzige Adress-Eigenschaft, die ein Update ändern darf, weil
Android Tokens nach eigenem Zeitplan neu ausstellt. Es spannt den Handshake neu: verified fällt
auf false zurück, und an das neue Token geht sofort eine frische PushVerification, die du genauso
behandelst wie die erste. url und keys bleiben nur beim Anlegen setzbar; wo eine verschlüsselte
Nutzlast hingeht, ändert man mit einem neuen Create.
Es gibt einen verpflichtenden Verifikations-Handshake, und er ist der springende Punkt. Beim
Anlegen schickt der Server sofort ein PushVerification-Objekt an die Adresse, die du angegeben
hast — per POST an den Endpunkt bei Web Push, als gewöhnliche FCM-Datennachricht bei Firebase, in
beiden Fällen dasselbe JSON. Du liest den Code daraus und schickst ihn per
PushSubscription/set-Update zurück. Bis du das tust, empfängt die Subscription nichts. Genau
das verhindert, dass der Endpunkt ein offenes Relay wird — ohne ihn könnte jede Person mit einem
Konto die Adresse einer Fremden registrieren. Plane diesen Roundtrip in deinem Einstieg ein.
Jeder Versuch, dein Gerät zu erreichen, wird serverseitig protokolliert, und du kannst der
Nutzerin sagen, wo sie nachsieht. Die Verifikation, auf die du wartest, und jede spätere
StateChange schreiben eine Zeile, die diese Nutzerin unter Einstellungen →
Benachrichtigungen sieht (pro Gerät: Transportweg, Bestätigungsstand und die letzte Zustellung
mit ihrem Ergebnis) und eine Administratorin unter /admin/push. „Die App hat sich registriert
und nie ihren Code bekommen" ist damit beantwortbar, ohne dass jemand ein Container-Log liest: Die
Zeile sagt, ob der Server es versucht hat und was der Transportweg geantwortet hat —
UNREGISTERED, ein 410 oder „übersprungen, diese Installation hat keine Schlüssel". Protokolliert
wird der @type der Nutzlast und sonst nichts von ihr, das Protokoll wird also nie zu einer
Aufzeichnung der Mail-Aktivität; bau nichts, das mehr voraussetzt.
2. EventSource (SSE) — für eine Sitzung im Vordergrund, kurz.
GET {eventSourceUrl}, text/event-stream. Sendet unmittelbar beim Verbinden ein
state-Ereignis (damit du ohne zusätzlichen Roundtrip weißt, wo du stehst), danach weitere
state-Ereignisse bei Änderungen, dazu ping-Ereignisse (standardmäßig 30 s, mindestens 5 s).
Lies das, bevor du es verwendest: Jede Verbindung belegt für ihre gesamte Lebensdauer einen PHP-Worker. Unter FrankenPHP ist das eine harte Kapazitätsgrenze — N verbundene Clients heißt N belegte Worker, und wenn alle vergeben sind, beantwortet der Server keine gewöhnlichen Anfragen mehr. Auf einem heimischen NAS ist N klein. Folglich schließt der Server jede Verbindung nach 300 Sekunden hart und erwartet, dass du dich neu verbindest. Verbinde dich mit Backoff neu, und trenn die Verbindung, sobald deine App in den Hintergrund geht. Zustellung im Hintergrund gehört auf Web Push, nicht hierher.
?closeafter=state gibt dir einen StateChange und ein sofortiges Schließen — der billige Weg,
neu zu synchronisieren, ohne eine Verbindung zu halten.
3. Polling — der Rückfall. Halt es selten; das hier ist irgendjemandes Raspberry Pi.
Was ein Push tatsächlich enthält:
{ "@type": "StateChange", "changed": { "7": { "Email": "9", "Mailbox": "3" } } }Bewusst winzig. JMAP pusht nie Mail-Inhalte, nur die Nachricht, dass sich ein State-Token
bewegt hat. Danach rufst du Email/changes auf, um herauszufinden, was. Verfolgte Typen:
Mailbox, Email, Thread, EmailSubmission. Identity ist ausgenommen — es ändert sich nur,
wenn die Nutzerin ihre eigenen Adressen bearbeitet, was sie gerade in deiner App getan hat.
Über FCM kommt dasselbe JSON als Datennachricht an, nie als notification-Nutzlast — die
Systemleiste darf nichts zeichnen, bevor deine App es gesehen hat, denn nur du weißt, ob die
Nutzerin ohnehin gerade auf dieses Postfach schaut. Das Objekt von oben ist der String-Wert eines
einzelnen Datenschlüssels:
{
"message": {
"token": "cX9…:APA91b…",
"data": { "payload": "{\"@type\":\"StateChange\",\"changed\":{\"7\":{\"Email\":\"9\"}}}" },
"android": { "priority": "HIGH", "ttl": "86400s", "collapse_key": "plmail-state-change" }
}
}RemoteMessage.getData()["payload"] ist also ein JSON-String, und dessen @type ist entweder
StateChange oder PushVerification. Die Collapse-Keys sind je Typ getrennt —
plmail-state-change und plmail-push-verification —, damit ein Rückstau von StateChanges auf den
neuesten zusammenfällt, ohne je eine unzugestellte Verifikation zu verwerfen. Nachrichten leben
24 Stunden.
Ein Token, zu dem FCM UNREGISTERED oder NOT_FOUND meldet, löscht die Subscription, genau
wie ein 404/410 bei Web Push. Kontingent-Ablehnungen und Firebase-Ausfälle tun das nicht.
Jedes Token kommt aus demselben State-Manager, den auch die /get- und /changes-Methoden
verwenden; ein Push und ein anschließendes /changes können sich also nie widersprechen.
Änderungen seitenweise: /changes liefert höchstens 256 Zeilen je Aufruf und setzt
hasMoreChanges. Die Grenze ist für Mobilgeräte bewusst bescheiden — lauf in einer Schleife, bis
sie sich löst.
Zu verstehen, woher die Mail kommt, hilft dir, in deiner Oberfläche die richtigen Erwartungen zu setzen:
| Kontoart | Einlesen | Sofortige Zustellung |
|---|---|---|
| IMAP |
webklex/php-imap, eine IDLE-Verbindung je Postfach, beaufsichtigt |
IMAP IDLE — funktioniert im LAN, keine öffentliche URL nötig |
| Gmail | Gmail REST + Batch API über OAuth2 | Google Cloud Pub/Sub watch → /gmail/push (verlangt öffentliches HTTPS und eine einmalige Einrichtung der Instanz) |
| Outlook / M365 | Microsoft Graph über OAuth2 (nicht IMAP — Exchange Online blockiert es unter den Security Defaults) | Graph-Subscriptions → /webhook/graph (verlangt öffentliches HTTPS) |
Ein geplanter Polling-Sync (alle 15 Minuten) sichert alle davon ab, wann immer kein Push verfügbar ist. Also: Deine App sollte allein aufgrund von Push nie behaupten, Mail sei „auf dem neuesten Stand", und sollte ein manuelles Aktualisieren anbieten. Ebenso: Hämmere nicht auf einen Sync-Endpunkt ein — der Server versucht es ohnehin schon.
Der Server holt die gesamte Historie eines Kontos — es gibt keine Aufbewahrungseinstellung,
die sich erweitern ließe. Was es gibt, ist ein Nachladen, das bei einem großen Postfach dauert:
Die neueste Mail kommt zuerst, der Rest folgt über spätere Läufe, alte Mail kann also
vorübergehend fehlen. Genau dafür meldet die Konto-Capability
urn:plmail:params:jmap:sync der Session backfillPending. Findet eine datierte Suche nichts und
ist dieses Flag gesetzt, lautet die ehrliche Meldung „ältere Mail trifft noch ein" — und nicht
„keine Ergebnisse", und niemals „erweitere das Sync-Fenster".
Konversationen werden derzeit über RFC-Message-IDs gebildet, nicht über Gmails eigene
threadId. Rechne mit gelegentlichen Abweichungen davon, was die Gmail-Weboberfläche
zusammenfasst.
Grob danach geordnet, wie sehr Nutzerinnen sie vermissen werden.
Lesen
- Vereinigter Posteingang über Konten hinweg (clientseitig zusammengeführt; siehe §3).
- Konversationen, die neueste ausgeklappt, ältere eingeklappt.
- Ungelesen / markiert / Anhang auf einen Blick.
- Volltextsuche mit der Operatorsyntax von oben.
- Anhänge und Inline-Bilder; die ursprüngliche (rohe) Nachricht auf Wunsch über den Blob
m-<id>.
Schreiben
- Verfassen, antworten, allen antworten, weiterleiten. Formatierter Text.
- Autovervollständigung von Kontakten (serverseitig aus synchronisierter Mail geerntet; frag
Contact/autocompleteunterurn:plmail:params:jmap:contacts— der Server reiht über das ganze Adressbuch und schlägt damit jeden lokalen Cache. Das Adressbuch des Betriebssystems bleibt eine sinnvolle Ergänzung für Menschen, denen der Nutzer noch nie geschrieben hat). - Senden aus jedem Konto und jedem sendefähigen Alias — zeig immer die Von-Auswahl.
- Automatisches Speichern von Entwürfen.
- Senden rückgängig machen (bei JMAP clientseitig; siehe oben).
- Terminiertes Senden, gesteuert vom Server statt von deinem eigenen Timer. Die Haltezeit steht im
Envelope (
HOLDFOR/HOLDUNTIL), undEmailSubmission/getmeldet die Freigabezeit und"pending"zurück, solange die Nachricht gehalten wird — eine Liste „Terminiert" lässt sich also aus der Antwort des Servers selbst bauen. Hier stand früher das Gegenteil, weil eine gehaltene Submission mitnotFoundantwortete; siehe Eine Submission zurücklesen.
Ordnen
- Label: anwenden, entfernen, anlegen, löschen. Verschachtelte Label gibt es im Datenmodell; die 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. Eine Eigenschaft auf
Konversationsebene (
MessageThread.snoozedUntil), offengelegt alsThread/set, einer plMail-Erweiterung, die sie undisNewannimmt und sonst nichts. Sie läuft über denselbenThreadSnoozeServicewie 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
- Erscheinungsbild —
Appearance/getundAppearance/setliefern das gesamte Objekt, samtfontFamily,fontScale,previewLines,unreadEmphasis,accountCorner,listAvatars, den drei Dichten pro Oberfläche und den nur lesbarenlogoStyle,logoMotifundlogoPaint(siehe §2). Bau das Token-System trotzdem; lies die Werte des Servers hinein, statt eigene Vorgaben zu erfinden. - Kontoliste und -reihenfolge.
- Benachrichtigungseinstellungen.
- Die Verwaltung von App-Passwörtern gibt es heute nur im Web; verlinke dorthin, statt sie nachzubauen.
- Sei optimistisch, dann gleiche ab. Änderungen an Stern, Gelesen-Status, Archiv und Label sollten in der Oberfläche sofort greifen und danach gegen den zurückgegebenen State abgeglichen werden. Der Server pflanzt ohnehin asynchron nach außen fort.
- Jede destruktive Aktion ist rückgängig zu machen, und das Rückgängig lebt für ein paar Sekunden in einem Toast am unteren Bildschirmrand. Papierkorb, Archivieren und Senden folgen in der Web-Oberfläche alle diesem Muster.
- Eine Konversation zu lesen markiert sie als gelesen, aber erst, nachdem sie tatsächlich angezeigt wurde — nicht beim Vorabladen.
- Zurück heißt immer zurück. Der mobile Wechsel Liste ⇄ Konversation ist ein echter Navigationsschritt.
- Offline ist ein vollwertiger Zustand, kein Fehler. Ein Heimserver ist öfter unerreichbar als einer in der Cloud. Zeig gecachte Mail, reihe Mutationen ein, sag klar, dass du offline bist, und versuch es erneut.
- Polle nie aggressiv. Keine Auffrischschleifen alle 5 Sekunden, kein offenes SSE im Hintergrund, kein erneutes Abfragen der ganzen Liste bei jedem Wechsel in den Vordergrund.
| Situation | Was du zeigst |
|---|---|
| 401 | „Dein App-Passwort wurde widerrufen oder ist ungültig" → erneute Anmeldung. Nicht stillschweigend wiederholen. |
unsupportedFilter |
Ein Fehler in deinem Query-Builder. Protokolliere ihn; zeig keine rohen JMAP-Fehler. |
tooLarge beim Upload |
Nenn die Grenze von 50 MB. |
| Server nicht erreichbar | „Dein Server ist nicht erreichbar" — mit dem Hostnamen. Die Leute hosten selbst; der Hostname ist für sie wirklich nützlich. |
| Leere Suchergebnisse | Hatte die Anfrage einen Datums- oder before:-Anteil und ist backfillPending gesetzt, sag, dass ältere Mail noch eintrifft. |
| Keine Konten verbunden | Verlinke tief in die Kontoeinrichtung der Web-Oberfläche; das Anlegen eines Kontos umfasst OAuth-Abläufe, die in einen Browser gehören. |
- Bau nicht gegen die HTML-/Turbo-Routen. Sie werden sich ändern.
-
Verdrahte
apiUrl,uploadUrl,downloadUrlodereventSourceUrlnicht fest. Lies sie jedes Mal aus dem Session-Objekt. -
Zerleg
blobIdnicht. Sie hat serverseitig einen Namensraum, und die Spezifikation verbietet es. - Nimm nicht ein einziges Konto an. Niemals.
- Halt keine SSE-Verbindung im Hintergrund. Du legst damit jemandes Mailserver lahm.
- Rendere Nachrichten-HTML nicht naiv auf dunklem Hintergrund — wähl je Nachricht eine Strategie, und invertiere Bildmaterial zurück, wenn du invertierst (siehe §2). Und rendere Blobs, die keine Bilder sind, nicht inline.
-
Erfinde keine Keywords — alles jenseits von
$seen,$flagged,$draft,$answeredwird abgelehnt. - Implementiere kein endgültiges Löschen. Es gibt es nicht, und es sollte es nicht geben.
- Bau keine Umgehung für eine fehlende Serverfunktion, ohne vorher zu fragen. Der Server wird aktiv weiterentwickelt, und ihn zu erweitern ist eine normale, verfügbare Option — siehe §0. Eine Umgehung, die Serverlogik im Client dupliziert, ist schlimmer als eine einzeilige Anfrage.
docker compose up --buildVorher ist nichts auszufüllen — die Geheimnisse werden beim ersten Start erzeugt, und die eine Einstellung ohne sinnvolle Voreinstellung (die Adresse, unter der plMail erreicht wird) wird im Einrichtungsbildschirm abgefragt. Öffne die App, leg die erste Administratorin an, füge ein Postfach hinzu.
Dann für deinen Client: Einstellungen → App-Passwörter → eines anlegen, und richte deinen
Client auf https://localhost/.well-known/jmap.
Nützlich während der Entwicklung:
docker compose exec php bin/console debug:routerdocker compose exec php bin/console app:mail:syncEin Test-Stack mit eigenem Postgres (damit du nie echte Mail anfasst) steht über
npm run test:env:up bereit und wird unter http://127.0.0.1:8001 ausgeliefert. Die vollständige
Referenz der Konsolenkommandos und die Testsuiten stehen in
CONTRIBUTING.md.
Endpunkte
| Pfad | Methode | Zweck |
|---|---|---|
/.well-known/jmap, /jmap/session
|
GET | Session finden |
/jmap/api |
POST | Alle Lese- und Schreibvorgänge |
/jmap/upload/{accountId} |
POST | Blob-Upload |
/jmap/download/{accountId}/{blobId}/{name} |
GET | Blob-Download |
/jmap/eventsource |
GET | SSE-Zustandsänderungen |
Grenzwerte
| Grenzwert | Wert |
|---|---|
| Uploadgröße | 50 MB |
| Gleichzeitige Uploads | 4 |
| Größe des Anfrageobjekts | 10 MB |
| Gleichzeitige Anfragen | 4 |
| Aufrufe je Anfrage | 32 |
Objekte je /get
|
500 |
Objekte je /set
|
500 |
Limit von Email/query
|
500 (harte Grenze) |
Zeilen aus /changes
|
256 je Aufruf |
| Lebensdauer einer SSE-Verbindung | 300 s |
Schreibdrosselung für lastUsedAt beim App-Passwort |
300 s |
Der Stack, zur Einordnung
Symfony 8 / PHP 8.4 · PostgreSQL 18 · Doctrine ORM · Symfony Messenger (Doctrine-Transport) ·
Mercure (Live-Aktualisierungen der Web-Oberfläche) · FrankenPHP · AssetMapper + Tailwind v4 +
Hotwire Turbo/Stimulus · libsodium-verschlüsselte Anmeldedaten · AGPL-3.0 · linux/amd64 und
linux/arm64.
Punkte auf der Server-Roadmap, die Clients betreffen werden
Die sind bereits geplant. Wenn dein Client einen davon früher braucht, sag es — Prioritäten sind verhandelbar, und eine konkrete Client-Anforderung ist der beste Grund, etwas vorzuziehen.
- Den Umbau auf Label abschließen (Label als das Konzept für die Nutzerin; Mailbox vollständig zur IMAP-Sync-Infrastruktur herabgestuft).
- Konversationsbildung über Gmails eigene
threadIdstatt über Message-IDs. - Eingehender IMAP-Flag-Abgleich über den IDLE-Strom.
- Oberfläche für verschachtelte Label.
- Ein Endpunkt zur JWT-Ausgabe für eine eigene App.
This page is generated from docs/de/CLIENT_DEVELOPMENT.md. Edit it there — changes made here are overwritten on the next push to main.
Using plMail
- Accounts and aliases
- Account health
- Filters
- Calendar
- Invitations and events from mail
- Reminders
- Connected calendars
- Sharing and booking
- Files and integrations
- Security
- Other clients
- Appearance
- Administration
Installing and running
- Docker Compose
- Platform notes
- Behind a reverse proxy
- Configuration reference
- Backup and restore
- Configuration backup
- Upgrading
- Demo mode
- Troubleshooting
Providers
How it works