Table of Contents
- Mail-Ingest
- Drei Syncer hinter einem Interface
- Die Post-Ingest-Pipeline
- Was auf eine Nachricht folgt, und wo es läuft
- Konversationsbildung
- Kategorisierung
- Labels sind der eine Mechanismus
- Was eine Operation bedeutet
- Zurückstellen ist Archivieren mit Wecker, ausgedrückt in Labels
- „Im Papierkorb“ ist gespeichert, und die Datenbank pflegt es
- Das Änderungsprotokoll, das JMAP liest
- Fallstricke
Mail-Ingest
Vom Anbieter in die Datenbank: wie aus Bytes eine Zeile Message wird, was mit ihr geschieht,
sobald es sie gibt, und warum jedes ordnende Konzept in plMail ein Label ist. Wie das Ergebnis
für eine Nutzerin aussieht, steht unter Mail; wie die Konten verbunden
werden, unter Konten und Aliase.
Drei Syncer hinter einem Interface
App\Domain\Interface\AccountSyncerInterface hat zwei Methoden, supports(Account) und
sync(Account): list<int>, und der Rückgabewert ist die Liste der Postfach-Ids, die berührt
wurden, damit der Aufrufer Folgearbeit pro Postfach anstoßen kann.
SyncAccountMessageHandler nimmt den getaggten Iterator, greift den ersten Syncer, der das
Konto für sich beansprucht, und weiß von keinem Anbieter irgendetwas.
| Syncer | Beansprucht | Form des Syncs |
|---|---|---|
App\Service\Imap\ImapAccountSyncer |
alles, was weder Gmail noch Microsoft ist | zuerst Ordnersuche, dann Nachrichten-Sync pro Postfach |
App\Service\Gmail\GmailAccountSyncer |
Account::isGmail() |
zuerst die Labelliste, dann historyId-Delta plus wiederaufnehmbarer Backfill |
App\Service\Graph\GraphAccountSyncer |
Account::isMicrosoft() |
Ordnersuche, dann eine delta-Kette pro Ordner |
Die drei unterscheiden sich in mehr als im Protokoll, und in den Unterschieden wohnen die Fehler.
IMAP synchronisiert Struktur und Inhalt über einen einzigen Einstiegspunkt.
ImapAccountSyncer lässt MailboxSyncer vor MessageSyncer laufen, damit Ordner, die ein
anderer Client angelegt hat, erst auftauchen und ihre Labelketten bekommen, bevor eine
Nachricht versucht, sich in eine davon aufzulösen. MessageSyncer holt in Stapeln zu 50 und
reicht die rohen RFC822-Bytes weiter, weil webklex sie nach dem Parsen behält — deshalb ist
IMAP der einzige Weg, auf dem das Original gratis gespeichert wird.
Gmail hat überhaupt keine Mailbox-Zeilen. GmailApiSyncer plant die Arbeit direkt auf dem
Konto und fächert SyncGmailMessageBatchMessage-Jobs zu je 100 Ids auf, während er mit der
Seitengröße 500 der API auflistet. Die gmailHistoryId des Kontos wird als Momentaufnahme
festgehalten, bevor irgendeine Nachricht geholt wird, und sie bedeutet ausschließlich „hier
setzt der inkrementelle Sync wieder an" — niemals „der Rückstand ist drin". Letzteres ist der
eigene Zustand von backfill(), mit eigenem BACKFILL_COOLDOWN von einer Stunde und
BACKFILL_MAX_ATTEMPTS von 24. Eine vorhandene historyId als „vollständig synchronisiert" zu
lesen ist genau das, was ein Konto früher bei dem stranden ließ, was der erste Lauf zufällig
geholt hatte.
Graph hat keinen Cursor auf Kontoebene. Ein Gegenstück zur einen historyId gibt es nicht,
also liegt der Delta-Zustand pro Ordner vor, gespeichert als Abbildung folderId => deltaLink
auf dem Account. Eine Delta-Abfrage ohne gespeicherten Link zählt den Ordner auf und gibt
einen zurück; derselbe Aufruf mit Link liefert nur noch Änderungen. Für Graph ist das ein
Vorgang. Für plMail sind es zwei: einem Ordner mit Link zu folgen ist Sache des Abgleichs, und
die Aufzählung, die den Link erst einbringt, ist Sache des Imports — siehe „Ein Erstimport ist
kein Abgleich" weiter unten.
Die scharfe Kante bei Graph sind die Ordnerbewegungen. Eine Nachricht aus einem Ordner
herauszuschieben erscheint im Delta des Quellordners als @removed und im Delta des Zielordners
als Zugang, und die Annahme war, dass beides sich ausgleicht. Das tut es nur, wenn beide
dieselbe Id tragen — und unveränderliche Ids sind genau das, was ein privates
outlook.com-Postfach nicht verlässlich hergibt. Die abtrennende Hälfte traf regelmäßig auf
nichts, und alte Ortslabels häuften sich an: Gelöschte Entwürfe lagen gleichzeitig unter
Entwürfe und im Papierkorb. Also ist das Anhängen von sich aus exklusiv und wartet nicht auf
seinen Partner: Der Ordner, in dem eine Nachricht zuletzt gesehen wurde, ist der Ordner, in dem
sie ist — was Exchange ohnehin meint.
Die Batch-Handler entdoppeln in PHP, bevor sie einfügen, doch diese Prüfung ist eine Lesung auf
veraltetem Stand. Was tatsächlich hält, wenn Stapel sich über Läufe oder Retries hinweg
überlappen, ist uniq_message_gmail_id_account und sein Graph-Gegenstück auf message, mit der
Anbieter-Id vorn in der Spaltenliste, damit der Index auch die reinen Id-Nachschläge bedient.
Ein erster Import ist kein Abgleich
Ein Abgleich holte früher neben der neuen Mail auch die Vorgeschichte eines Kontos: ein einziger
Job, der jeden IMAP-Ordner von der ältesten Nachricht bis zur neuesten las oder jede
Gmail-Nachrichten-Id auflistete, bevor er eine abrief. Bei einem Konto mit zwanzigtausend
Nachrichten hielt dieser Job die Warteschlange ingest so lange besetzt, wie er eben brauchte,
zeigte die meiste Zeit einen leeren Posteingang und stellte die neue Mail aller anderen Konten
dahinter an.
Beides ist jetzt getrennt. Ein Abgleich sorgt dafür, dass neue Mail gesehen wird, und hört auf;
die Vorgeschichte gehört App\Service\Mail\MailImporter, auf der Warteschlange
ingest_backlog mit eigenem Worker.
- Eine Nachricht ist eine Seite.
ImportMailMessageträgt eine Konto-Id und sonst nichts; ihr Handler holt eine Seite und stößt die nächste an. Nirgends darin steckt ein langer Job, also wechseln sich die Seiten mehrerer Konten in der Warteschlange ab, und jedes hat einen brauchbaren Posteingang, bevor irgendeines sein Archiv hat. - IMAP liest jeden Ordner von oben nach unten. Beim ersten Kontakt setzt
ImapImportPlanlastSeenUidan das obere Ende des Ordners undimportFloorUiddirekt darüber. Neue Mail ist alles oberhalb des ersten; Vorgeschichte ist alles unterhalb des zweiten, undImapMailboxImporterarbeitet sich fünfzig Nachrichten pro Aufruf nach unten. Eine Seite entsteht, indem der Server gefragt wird, welche UIDs unterhalb der Marke existieren, und die schon gespeicherten herausgenommen werden — eine Nachricht, die da ist, wird nie zweimal abgerufen. - Ordner kommen in der Reihenfolge, die jemand sich wünschen würde: Posteingang, Gesendet,
Entwürfe, eigene Ordner, das Archiv und zuletzt Spam und Papierkorb.
MailboxSpecialUse::importRank()ist die Regel. - Gmail fragt zuerst namentlich nach dem Posteingang, dann blättert es die Auflistung des ganzen Postfachs Anfrage für Anfrage durch; das Seiten-Token liegt zwischen den Jobs auf dem Konto.
- Gmails Kontingent wird gelernt, nicht eingestellt. Google erlaubt einem Nutzer eine Zahl
von Kontingent-Einheiten pro Minute und sagt erst, wie viele, wenn es eine Anfrage ablehnt.
GmailQuotaPacerbeginnt bei 15.000, liest das Limit aus der ersten Ablehnung (ein seit Mai 2026 angelegtes Projekt hat 6.000) und taktet von da an jedes Konto auf 85 % davon, fünf Nachrichten pro Anfrage — Google lehnt ab, was den Vorrat einer Minute in einer Sekunde verbraucht — und nach jeder Ablehnung bei einem schon bekannten Limit eine Weile langsamer. Eine Nachricht zählt 60 Einheiten: Googles Tabelle nennt 20, und das kostet ein Abruf ohne Inhalt;app:gmail:quota-probehat für die Formatefullundraw60 gemessen. Ein gelerntes Limit wird nach einem Tag vergessen, damit ein angehobenes wiedergefunden wird. Was ein Konto verbraucht hat und wann es zuletzt abgelehnt wurde, liegt in einem Cache-Pool in der Datenbank, sodass alle Worker aus einem Kontingent schöpfen. Eine Ablehnung ist auf diesem Weg nirgends ein Fehler: Ein Stapel, eine Import-Seite oder ein Sync, der auf eine trifft, wird zurückgestellt und läuft, wenn die Minute des Kontos vorbei ist. - Eine Gmail-Nachricht gehört zu dem Postfach, in dem sie liegt. Der Stapel-Handler gibt
eine Nachricht nur dann einem anderen verbundenen Konto, wenn dieses den besseren Anspruch
hat (Gmailify: ein Geschwisterkonto, das in ihren Headern genannt ist); alles andere im
Postfach gehört dem Gmail-Konto, auch von einer „Senden als“-Adresse verschickte Mail, von
einer hier nicht verbundenen Adresse weitergeleitete Mail und Gmail-Entwürfe, die als
Entwürfe ankommen. Die Auflistung fragt auch nach Spam und Papierkorb (
includeSpamTrash), diemessages.listsonst auslässt undmessagesTotalmitzählt. - Eine Gmail-Nachricht, die angesehen und nicht gespeichert wird, wird notiert. Der
Stapel-Handler lehnt einen Chat ab — die einzige Art, für die es keinen Platz gibt — und
kann eine Nachricht nicht speichern, die Gmail auflistet und mit 404 beantwortet. Jede
bekommt eine Zeile in
gmail_skipped_message(App\Entity\Mail\GmailSkippedMessage) mit dem Grund, ihren Labels und ihren Adress-Headern. Die Auflistung behandelt eine notierte id wie eine gespeicherte, die Nachricht wird also nicht jede Stunde erneut abgerufen, und ein Import, bei dem jede ausstehende Nachricht eine Antwort hat, endet in diesem Durchlauf statt an seiner Versuchsgrenze; die Fortschrittszeile zählt sie als erledigt, denn Googles Gesamtzahl enthält sie.app:gmail:skippedgibt die Notizen nach Art gruppiert aus, und--forgetlöscht sie für ein Konto, sodass die nächste Auflistung erneut fragt. Eine Zeile verschwindet, wenn ihre Nachricht doch gespeichert wird. - Microsoft liest die erste Delta-Runde jedes Ordners seitenweise. Ein Ordner ohne
Delta-Link wird auf dem Konto eingeplant (
Account::$graphImport), statt vom Abgleich aufgezählt zu werden, undGraphApiSyncer::importPage()geht diese Runde in Schritten von fünfzig Nachrichten durch, die neuesten zuerst, in derselben Ordnerreihenfolge nachLabelRole::importRank(). Die Seite, die die Runde beendet, bringt den Delta-Link des Ordners mit, und ab da folgt ihm der Abgleich. Bis dahin fragt der Abgleich einen solchen Ordner nur nach dem, was seit der Einplanung eingegangen ist. Ein abgelaufener Delta-Link gibt seinen Ordner an den Import zurück, statt im Abgleich neu aufgezählt zu werden. - Eine Nachricht, die sich nicht speichern lässt, kostet nur sich selbst. Die Marke bleibt, wo sie ist, und die Seite wird erneut angefragt; nach fünf Versuchen rückt die Marke an ihr vorbei, und der Ordner geht weiter.
Jeder gewöhnliche Abgleich endet in MailImporter::ensureRunning(), das einen Import startet,
der Arbeit hat und seit fünf Minuten still ist. Das startet den Import eines neuen Kontos, und
es startet einen neu, dessen Kette von Nachrichten gerissen ist.
„Importiert dieses Konto noch" ist InitialImportState, und für IMAP wird es an
importFloorUid der Ordner abgelesen: syncedAt eines Ordners sagt das nicht mehr, weil sein
erster Abgleich jetzt sofort endet und die Vorgeschichte noch aussteht. Ein Microsoft-Konto
importiert, solange Account::$graphImport einen Ordner aufführt.
Eines ändert ein Microsoft-Import am Abgleich: Eine Nachricht, die aus einem Ordner entfernt
wurde, ist nur dann gelöscht, wenn sie in keinem anderen angekommen ist, und ein Ordner, der
noch gelesen wird, kann das nicht sagen. Solche Entfernungen bleiben auf dem Konto
(Account::$graphRemovals) und werden vom ersten Abgleich beurteilt, bei dem jeder Ordner
geantwortet hat.
Die Post-Ingest-Pipeline
Sobald ein Stapel Message-Zeilen existiert und geflusht ist, läuft
App\Service\Mail\PostIngestPipeline als gemeinsamer Durchgang darüber. Die drei Sync-Pfade
trugen früher je eine eigene Kopie davon; sie stimmten überein, und genau das war das Problem —
die Reihenfolge ist subtil genug, dass drei Kopien drei Gelegenheiten zum Auseinanderlaufen
sind, und alles, was auf neue Mail reagieren wollte, musste in alle drei verdrahtet werden.
Die Vorbedingung steht im Docblock und ist nicht verhandelbar: Der Aufrufer hat jede Nachricht
bereits persistiert und geflusht. Ids müssen existieren, bevor die Konversationsbildung sie
abfragt, und MailRuleEngine matcht in SQL gegen search_vector, eine generierte Spalte — eine
Nachricht, die die Datenbank nicht erreicht hat, ist für die eigenen Regeln der Nutzerin
unsichtbar.
Pro Nachricht, in dieser Reihenfolge:
MailBodySanitizer::sanitize()fülltbodyHtmlSafe. Das unbereinigtebodyHtmlbleibt mit Absicht erhalten — siehe unten.RawMessageResolver::store(), aber nur, wenn der Aufrufer Bytes mitgeliefert hat. Gmail und Graph übergeben null und holen bei erster Verwendung nach, weil es für sie ein zweiter API-Aufruf ist.MailChangeRecorder::emailChanged(..., created: true, thread: null)— das JMAP-Create, und zwar ohne Konversation, weilassignThread()noch nicht gelaufen ist und eine Konversation, die dort entsteht, bis zum nächsten Flush keine Id hat. Sie vorher zu lesen veröffentlichte die Id 0 an jeden verbundenen Client.MessageCategorizer::categorize().MessageThreader::assignThread(), in eintrygehüllt — ein Fehlschlag bei der Konversationsbildung wird an der Nachricht protokolliert, darf aber nie den Stapel kosten.
Dann, einmal für den ganzen Stapel: MailRuleEngine::applyToBatch() (eine Abfrage pro Regel,
nach der Konversationsbildung, damit Archivier- und Papierkorb-Aktionen die Konversation jeder
Nachricht erreichen), ein Flush, ein zweiter Durchgang, der Konversations-Ids pro Konto für
MailChangeRecorder::threadsTouched() einsammelt, und ein zweiter Flush für die Zeilen des
Änderungsprotokolls.
Die Filter-Aktion In Kategorie ablegen läuft im selben Durchgang und ist die einzige Aktion,
die eine Kategorie schreibt. Sie schreibt nicht Message::$category: Fünf Dinge berechnen diese
Spalte aus gespeicherten Daten neu (Ankunft, app:backfill category, die Neusortierung, die eine
Einstellungsänderung einreiht, die Freigabe durch den Assistenten, die Gmail-Anreicherung), und jedes
davon würde ein dort abgelegtes Urteil löschen. Das Urteil steht stattdessen in
Message::$ruleCategory, das nichts neu berechnet, und RuleCategoryFiler lässt dann die
Konversation über MessageThreader::refreshCategory() neu auflösen — neueste gewinnt, pin-bewusst,
Hold-bewusst, wie bei der eingebauten Kategorie. MessageThread::$userCategory ist die Seite der
Konversation, aufgelöst von denselben zwei Neuberechnungs-Statements wie category. Eine Nachricht
wird nur abgelegt, wenn SenderAuthentication Pass sagt; wessen Urteil geglaubt wird, steht in
dessen Docblock. Der Filter legt auch Mail mit Gmail-Labels ab: Die Kategorien des Anbieters haben
Vorrang vor plMails eigener Sortierung, nicht vor einer Anweisung, die die Nutzerin geschrieben hat.
Eine eingebaute Kategorie, die die Nutzerin ausgeschaltet hat (CategorySorting::$disabled),
bekommt keine Mail. MessageCategorizer::explain() macht aus dem, was die Kaskade entschieden hat —
Header-Regel, Gmail-Label oder Assistent —, Allgemein mit dem Grund switched_off, statt die nächste
Regel zu fragen; „Foren ist aus“ macht eine Mailingliste also nie zu Werbung. Eine Kategorie aus- oder
einzuschalten reiht dieselbe Neusortierung ein wie die anderen Sortier-Einstellungen. Beide
Neuberechnungs-Statements schreiben nur die Konversationen, deren Tab sich ändert, und geben sie
zurück, und jeder Aufrufer trägt sie als JMAP-Thread-Änderungen ein — so erfährt ein Handy von einer
Neusortierung.
Zurück kommt PostIngestResult — Nachrichten in Ingest-Reihenfolge, besitzende Konten nach Id
und Konversations-Ids nach besitzendem Konto —, weil die drei Aufrufer nicht gleich zu Ende
kommen: IMAP veröffentlicht sein Mercure-Update und stößt die Kontakt-Ernte eine Ebene höher in
SyncImapMailboxMessageHandler an, während Gmail und Graph beides direkt erledigen.
Steps: der Erweiterungspunkt
App\Domain\Interface\PostIngestStepInterface wird automatisch mit app.post_ingest_step
getaggt und hat eine Methode, afterCommit(PostIngestResult). Drei Regeln erzwingt die
Pipeline:
Es gibt genau einen Haken, und er läuft nach dem letzten Flush. Ein Step sieht nie einen halbfertigen Stapel. Ein Haken innerhalb der Schleife ließe einen Step eine Nachricht ändern, die die Regel-Engine danach aus veraltetem Datenbankstand bewertet — und Mail landete an Stellen, die die Regeln der Nutzerin nicht erklären.
Steps stoßen an, sie arbeiten nicht. afterCommit() läuft auf dem Worker, der eine
IMAP-Verbindung oder ein Graph-Rate-Limit-Budget hält; ein Parse, ein HTTP-Aufruf oder eine
Bilddekodierung gehören in einen eigenen Handler — und ebenso Arbeit, die bloß schnell ist; so
kamen die Insight- und Vorschlags-Steps dazu, inline zu laufen, und so hörten sie damit auf.
HarvestContactsStep ist die eine verbliebene Ausnahme: Er liest Zeilen, die schon im Speicher
liegen, und speist die Kategorisierung.
App\Service\Mail\PostIngest\ExtractEventsStep ist die Form zum Abschauen — er sammelt Ids ein
und schickt ExtractEventsMessage ab, sonst nichts.
Steps können keinen Sync scheitern lassen. notifySteps() fängt und protokolliert, was ein
Step wirft, und macht mit dem nächsten weiter. Ein kaputter Step kostet das, was er tun wollte,
niemals die Mail.
Zwei Zweige laufen mit Absicht nicht durch die Pipeline: der Gmailify-Anspruch von IMAP und
SyncGmailMessageBatchHandler::enrichExisting(). Beide zeigen eine Zeile neu aus, die schon
einmal hindurchgegangen ist — ein zweiter Lauf würde also ein Create für eine Id nachtragen, die
JMAP-Clients längst halten, und Regeln erneut über Mail laufen lassen, die die Nutzerin
inzwischen von Hand eingeordnet haben kann.
Was auf eine Nachricht folgt, und wo es läuft
ingest ist zum Abholen von Mail da und für sonst nichts. Alles, was ein Step einreiht, geht
auf eine von drei anderen Warteschlangen — und welche, hängt von der Mail ab, nicht von der Art
der Arbeit:
| Warteschlange | Prozess | Was darauf liegt |
|---|---|---|
enrich_live |
worker-live |
Folgearbeit für Mail, die gerade eben angekommen ist |
enrich |
worker-enrich |
Folgearbeit für aktuelle Mail aus einem Import; Suchindizierung |
enrich_backlog |
worker-enrich, nach enrich |
Alte Mail, die der Assistent nach dem Import einsortiert |
Früher war das eine Warteschlange. Ein erster Import legte pro Stapel einen Modellaufruf, eine
Terminerkennung und einen Bestätigungs-Scan auf ingest, neben die Abgleiche — und der eine
Worker, der Mail abholt, fragte stundenlang ein Modell nach Newslettern von 2019, während neue
Mail dahinter wartete.
Aktuell. App\Service\Mail\PostIngest\RecentMailPolicy entscheidet das, nach dem Datum der
Mail selbst — receivedAt, dann sentAt — gegen ein Fenster von 30 Tagen
(ENRICH_RECENT_DAYS). Jeder Step reiht nur für aktuelle Mail Arbeit ein. Ältere Mail bekommt,
was die Pipeline selbst tut — eine Zeile, eine Konversation, die Kategorie der Regelkaskade, die
Regeln der Nutzerin, ihre Kontakte — und keinen Job. Die Backfill-Aufgaben (app:backfill insights, event-extraction, …) sind der Weg, Vergangenes zu lesen, wenn jemand es will.
Live. App\Service\Mail\InitialImportState sagt, ob ein Konto sein Postfach noch zum ersten
Mal hereinholt: Gmail über needsBackfill(), Graph darüber, ob es einen Delta-Link gibt und kein
Ordner mehr zu lesen ist, IMAP darüber, ob kein zum Abgleich aktivierter Ordner noch
Vorgeschichte offen hat. Die Pipeline
fragt einmal pro Stapel und trägt die Antwort in PostIngestResult::$live; EnrichmentRouter
macht daraus einen TransportNamesStamp. Mail eines fertigen Kontos geht auf enrich_live,
dessen Worker nie Import-Arbeit vor sich hat. Im Zweifel heißt es „importiert noch" — ein
Gmail-Backfill gilt bis zu eine Stunde zu spät als abgeschlossen —, weil der umgekehrte Fehler
ein ganzes Postfach als live einreihen würde.
Alte Mail und der Assistent. App\Service\Ai\ClassificationCatchUp, alle Viertelstunde von
app:ai:classify-backlog gestartet, reiht die neueste Mail, nach der der Assistent noch nie
gefragt wurde, auf enrich_backlog ein — aber erst, wenn jedes Konto dieser Person fertig
importiert ist, und nur, solange die Warteschlange leer ist; das ersetzt einen eigenen Zustand.
Message::$aiCategorisedAt ist die Fortschrittsmarke.
Ein Modell-Host. Getrennte Worker enden trotzdem an einem Host, der eine Anfrage nach der
anderen beantwortet. App\Service\Ai\LiveMailPriority lässt die Massen-Klassifizierung vor jedem
Aufruf auf enrich_live schauen und zurücktreten, solange dort etwas fällig ist — Live-Mail
wartet so höchstens hinter einem laufenden Aufruf.
Mail für den Assistenten zurückhalten
Für jemanden, der vom Assistenten sortieren lässt, ist die Kategorie der Regeln eine Vermutung,
die gleich ersetzt wird. Sofort angezeigt, erschien die Mail unter „Allgemein" und wanderte
Sekunden später zu „Werbung". Wo das Urteil die Antwort also noch ändern kann, wird die
Nachricht zurückgehalten: Message::$categoryHeldAt wird vor der Konversationsbildung
gesetzt, ihre neue Konversation bekommt keine Kategorie — was jede Tab-Abfrage und
EmailFilterCompiler::threadCategory() schon als „in keinem Tab" lesen —, und eine Antwort in
eine bestehende Konversation lässt diese, wo sie ist.
App\Service\Mail\ClassificationHold ist die Entscheidung, und sie ist eine Liste von Gründen,
nicht zurückzuhalten: Das Zurückhalten ist unter Administration → KI abgeschaltet; die Mail
gehört zu einem Import; sie ist alt oder die eigene gesendete Kopie des Kontos; oder
MessageCategorizer::verdictCouldDecide() sagt, dass die Antwort schon feststeht — wer nach
Regeln sortiert, ein Gmail-Label, eine bekannte Korrespondenzadresse.
Drei Dinge beenden das Zurückhalten, und App\Service\Mail\HeldMailReleaser ist das eine Ende,
das sie teilen:
ClassifyMailHandler, wenn das Modell antwortet.ReleaseHeldMailMessage, im Moment des Zurückhaltens mit einer Verzögerung vonAiSettings::$holdMaxSeconds(Vorgabe 60, 2–300) abgeschickt. Meist kommt sie an und findet nichts Zurückgehaltenes mehr. Sie hat eine eigene Warteschlange,release, und einen Worker, der sonst nichts tut (worker-release): Der Live-Worker steckt womöglich gerade in genau dem Aufruf, der zu lange dauert, und die Freigabe darf nicht auf ihn warten — und auch nicht auf einen Versand.app:mail:release-held, jede Minute, falls keiner der beiden Jobs lief.
Dass das Warten endet, beendet nicht die Frage. Eine Freigabe ohne Urteil ordnet die Mail unter der Kategorie der Regeln ein, die beim Ingest genau dafür geschrieben wurde; der Modellaufruf läuft weiter, und wenn seine Antwort eintrifft, verschiebt sie die Mail und sagt es den offenen Seiten. Nach der Nachricht wird einmal gefragt.
category_held_at und category_released_at bleiben erhalten, und ihre Differenz ist die Zahl,
die Administration → KI neben dem Schalter zeigt — Median, 95. Perzentil und wie viele
Nachrichten angezeigt wurden, bevor das Modell geantwortet hatte.
Konversationsbildung
App\Service\Imap\MessageThreader::assignThread() ist eine Kaskade aus drei Stufen, und jede
existiert, weil die darunter nicht gut genug ist. Eine vierte Stufe schaut danach in die andere
Richtung.
1. Die Konversations-Id des Anbieters selbst, aus Message::$providerThreadKey. Gmail und
Graph haben die Konversation bereits so gruppiert, wie die Nutzerin sie in ihrem Web-Client
sieht, und lokal Hergeleitetes kann das nicht schlagen.
Die Falle hier ist die Stapelverarbeitung. Das Repository sieht nur, was geflusht ist, und ein
Sync-Stapel ordnet jede Nachricht zu, die er gebaut hat, bevor er am Ende einmal flusht — zwei
Nachrichten einer Gmail-Konversation im selben Stapel verfehlten also beide den Nachschlag,
legten je eine Konversation an, und das zweite INSERT lief in
uniq_message_thread_provider_key_account und riss den ganzen Stapel mit. providerThread()
hält deshalb eine Abbildung im Speicher mit dem Schlüssel accountId|providerThreadKey,
gedeckelt bei PROVIDER_THREAD_CACHE_LIMIT (500), und prüft jeden Treffer mit
EntityManager::contains() nach, weil der Manager eines Workers zwischen Nachrichten geleert
wird und alles, was er nicht mehr verwaltet, eine tote Referenz ist.
2. RFC 5322 References / In-Reply-To. Ist eine referenzierte Nachricht für dieses Konto
schon synchronisiert, wird ihre Konversation genommen. Wenn nicht, wird trotzdem eine neue
Konversation mit ThreadingMethod::References angelegt — die Methode hält fest, was die
Nachricht anbietet, nicht ob zufällig ein Treffer zustande kam.
3. Betreffabgleich, und nur als Rettung. SUBJECT_FALLBACK_WINDOW steht auf -30 days:
lang genug für eine träge Konversation, kurz genug, dass ein wiederkehrender Betreff neu
anfängt, statt eine abgestandene Konversation zu verlängern. Er greift nur bei Nachrichten
mit einem Antwortpräfix (REPLY_PREFIX_PATTERN deckt
re|fwd|fw|aw|wg|antw|sv|vs|res|rif|tr|doorst ab, in den Sprachen, in die plMail übersetzt
ist, plus denen, die verbreitete europäische Clients ausgeben), und die Kandidatin muss zudem
eine beteiligte Person teilen. Eine Nachricht, die selbst keine Antwort ist, beginnt immer ihre
eigene Konversation — sonst fällt jede „Ihre Bestellung ist unterwegs"-Benachrichtigung, die je
eintraf, in eine einzige endlose Konversation zusammen.
4. Die andere Richtung: Mail, die diese Nachricht schon beantwortet. Stufe 2 und 3 schauen von
einer Antwort auf das, was sie beantwortet, also hing das Ergebnis allein von der Reihenfolge der
Synchronisation ab. Ordner werden nacheinander synchronisiert, und Antworten im INBOX kommen
regelmäßig vor der Nachricht aus Sent Items an, auf die sie antworten; sie fanden nichts, legten
eigene Konversationen an und blieben dort (#172). Nach Stufe 2 und 3 fragt adoptReplies()
MessageRepository::findRepliesToForAccount() nach jeder Nachricht des Kontos, deren
in_reply_to oder thread_references diese Message-ID nennt, und ThreadMerger faltet jede
ihrer Konversationen in diese hier. Ein GIN-Ausdrucksindex, idx_message_referenced_ids, hält
diese Abfrage davon ab, pro Nachricht das ganze Postfach zu durchsuchen. Stufe 1 überspringt das:
Eine Konversations-Id des Anbieters hat die Antworten schon zusammengefasst.
Erhalten bleibt die Konversation mit der kleineren Id, und eine noch nicht geflushte gilt als die
neueste. So überlebt die Konversation, auf die ein JMAP-Client, eine KI-Zusammenfassung oder eine
Insight-Karte schon zeigt; eine Konversation mit Anbieter-Schlüssel wird nie aufgelöst. Zähler
werden addiert, Labels vereinigt, die neueste Mail bestimmt lastMessageAt und die Kategorie,
außer eine ist festgepinnt, Stern und Zurückstellen werden übernommen, und der Betreff kommt von
der Seite, die die beantwortete Nachricht hält. Die aufgelöste Konversation wird JMAP als
gelöscht gemeldet, ihre Nachrichten als geändert. Die Zusammenführung flusht nie: Nachrichten
werden über die Unit of Work verschoben, weil die Konversation einer Antwort innerhalb eines
Batches womöglich nur im Speicher existiert.
Konversationsbildung über Gmails eigene threadId steht auf der Roadmap und ist nicht erledigt:
Die Id wird auf Message mitgeführt, aber Stufe 2 arbeitet für Konten, auf die Stufe 1 nicht
zutrifft, weiterhin über die Message-ID aus dem RFC.
Kategorisierung
App\Service\Mail\MessageCategorizer bestimmt den Posteingangs-Tab allein aus persistierten
Daten. Das ist der ganze Entwurf: Dieselbe Logik läuft beim Sync und in
app:backfill category, ohne erneutes Abholen und ohne Resync. Header werden roh und
unnormalisiert gespeichert, und jeder Zugriff geht durch einen Helfer, der die gespeicherten
Schlüssel bei Bedarf kleinschreibt — ein zusätzliches Signal braucht deshalb nie einen Resync,
denn der Header steht schon auf der Zeile.
Die Kaskade, der Reihe nach, und die Reihenfolge ist tragend:
- Gmails eigene
CATEGORY_*-Labels gewinnen rundheraus, sobaldMessage::$gmailLabelIdsnicht null ist. Gmail hat die Nachricht bereits eingeordnet, und ihm innerhalb eines Gmail-Kontos zu widersprechen heißt: ein Tab, der nicht zur Web-Oberfläche passt. - Vorrang für Korrespondenz. Hat die Nutzerin diesem Absender je geschrieben, ist es Allgemein, egal welcher Massenversand-Header dabeisteht.
- Foren vor Werbung. Diskussionslisten tragen ebenfalls
List-Unsubscribe, Werbung zuerst zu prüfen würde also jede Mailingliste als Marketing einsortieren. - Soziale Netzwerke über die Absenderdomain, aus einer kleinen, signalstarken Liste
SOCIAL_DOMAINS. - Updates, dann Allgemein als Vorgabe.
explain() liefert dieselbe Entscheidung samt der Stufe, die sie getroffen hat, und dem
Signal, auf das sie angesprungen ist — auf Anfrage neu berechnet statt gespeichert. Weil nur
persistierte Daten gelesen werden, kostet die Erklärung einen Durchgang über Header, die
ohnehin im Speicher liegen, und sie kann nicht von einer Spalte abweichen, die eine ältere
Fassung dieser Regeln geschrieben hat. Das ist es, was die „Warum ist das hier?"-Auskunft in der
Nachrichtenansicht rendert.
Die Tabs lesen das nicht. Sie filtern auf MessageThread::$category, eine gespeicherte Spalte,
die aus den Nachrichten des Threads nach dem Jüngsten aufgelöst wird — die Live-Erklärung und der
Tab können also auseinanderlaufen, und was sie zusammenhält, ist, wer die Spalte zuletzt
geschrieben hat. Drei Stellen tun das: der Sync über PostIngestPipeline, noch vor dem
Threading; app:backfill category mit einem DISTINCT ON je Konto; und
SyncGmailMessageBatchHandler::enrichExisting(), wo eine Neubeschriftung durch Gmail
ankommt.
Die letzte wiegt schwerer, als sie klingt. Gmail sortiert nach der Zustellung ein und sortiert
neu ein, sooft du Post zwischen den Tabs verschiebst — CATEGORY_* kommt und geht also auf
Zeilen, die plMail seit Monaten hält, und genau die noch einmal zu lesen ist der Zweck des
Relabel-Abrufs. Die Anreicherung überschreibt gmailLabelIds, also das einzige Signal, das die
Kaskade auf einer Gmail-Zeile überhaupt befragt, und berechnet die Kategorie deshalb im selben
Atemzug neu und übernimmt sie über MessageThreader::refreshCategory() auf den Thread. Ohne
diesen Schritt stand in der Nachrichtenansicht „Werbung — sagt Gmail" über Post, die der
Posteingang bis zum nächsten Backfill unter Allgemein ließ.
Labels sind der eine Mechanismus
App\Entity\Label\Label gehört der Nutzerin, nicht einem Konto. Wo ein Label auf
Anbieterseite materialisiert ist, steht in App\Entity\Label\LabelBinding, eine Zeile pro
(Label, Konto), die die Gmail-Label-Id, die Graph-Ordner-Id oder die Verknüpfung zum
IMAP-Postfach trägt. Zwei Konten, die einen Ordner namens „Belege" synchronisieren, laufen auf
ein Label mit zwei Bindings zusammen, und der vereinigte Posteingang fällt aus dem Modell
heraus, statt beim Rendern durch Zusammenführen über den Namen rekonstruiert zu werden — was
SidebarCounts früher tat.
Mailbox ist zu reiner IMAP-Sync-Infrastruktur herabgestuft und erreicht sein Label über das
Binding. Eine zweite Verknüpfung auf Mailbox würde spiegelbildlich genau die Asymmetrie
wiederherstellen, die die Binding-Tabelle beseitigen soll.
App\Service\Label\LabelResolver ist das eine Find-or-Create, und immer in zwei Schritten:
erst das Label auf Nutzerebene auflösen, dann sein LabelBinding für das gerade
synchronisierte Konto. Beide Sync-Schichten benutzen ihn — MailboxSyncer beim Abbilden von
IMAP-Ordnern, GmailLabelSyncer beim Aufspalten von Gmails Benennung Work/Invoices in eine
Elternkette —, sodass Verschachtelung gleich funktioniert, egal welcher Anbieter sie erzeugt
hat. Zwischengespeichert werden Ids von Entities, nie Entities selbst, damit langlaufende
Handler ein em->clear() überleben.
Was eine Operation bedeutet
App\Service\Mail\ThreadStatusUpdater besitzt die Bedeutung von Markieren, Archivieren,
Wegwerfen, Labeln und Als-gelesen-Markieren. Jedes davon ist zuerst eine Labeländerung —
die Datenbank ist die Quelle der Wahrheit — und wird danach asynchron von
App\Service\Label\LabelChangePropagator weitergegeben. Archivieren ist das Entfernen des
Posteingang-Labels. Papierkorb heißt: Papierkorb dazu, Posteingang weg.
Jede Methode dort endet mit einem Flush und protokolliert die JMAP-Änderung zuerst, und diese Reihenfolge ist der Sinn der Klasse: Eine Änderung aus der Web-Oberfläche, die das Protokoll überspringt, ist für verbundene JMAP-Clients unsichtbar, bis zufällig etwas anderes die Konversation berührt — und früher wurden die beiden nur dadurch im Gleichschritt gehalten, dass jede Controller-Aktion daran dachte, beides aufzurufen.
ThreadLabelSynchronizer macht die Labels einer Konversation anschließend zur Vereinigung
der Labels ihrer Nachrichten — Gmail-Semantik, bei der eine Konversation unter einem Label
erscheint, sobald irgendeine Nachricht darin dieses Label trägt. Wegen dieser hergeleiteten
Vereinigung liest EmailMapper Message::$labels und nie thread_label: Die Vereinigung zu
lesen würde für jede Nachricht einer Konversation jedes Postfach melden.
„Ihre Nachrichten" sind die, die im Speicher auf die Konversation zeigen, nicht der Inhalt von
$thread->messages: Die Regeln setzen Labels auf neue Mail nach der Konversationsbildung und
vor dem Flush des Batches, wenn diese Collection die neue Nachricht noch nicht enthält, und eine
Vereinigung darüber nahm der Konversation das Label einer Regel gleich wieder weg.
Die Abbildung auf den Anbieter, die LabelChangePropagator vornimmt, ist die Stelle, an der die
drei Anbieter aufhören, austauschbar zu sein:
- Gmail — alles ist eine Labeländerung über
messages.batchModify. - IMAP — Stern und Gelesen werden auf Flags abgebildet; Archivieren, Papierkorb und Löschen auf Verschiebungen. Ein eigenes Label anzuhängen bleibt rein in der Datenbank, denn der physische Ordner bleibt unangetastet, solange das Ortslabel steht. Eines abzunehmen löst nur dann eine physische Verschiebung aus, wenn das abgenommene Label das Ortslabel der Nachricht war, denn eine Nachricht muss irgendwo wohnen. Der Ersatzort wird in dieser Reihenfolge bestimmt: ein verbliebenes System-Label Papierkorb/Spam mit hinterlegtem Ordner, dann ein verbliebenes eigenes Label mit Ordner (das zuletzt angehängte gewinnt), dann — wenn nichts Ordnergestütztes übrig ist — das Archiv.
- Graph — eine Ordnerverschiebung ersetzt den Ort, statt ihm etwas hinzuzufügen.
Für IMAP-Verschiebungen müssen die Aufrufer die Nachrichten vor flush() übergeben, damit
message->mailbox noch den Quellordner zeigt; der Propagator hält sich die Abbildung
messageId => sourceMailboxId fest und zeigt die Nachricht optimistisch schon auf das Ziel um.
Zurückstellen ist Archivieren mit Wecker, ausgedrückt in Labels
App\Service\Mail\ThreadSnoozeService ist der klarste Fall für die Regel vom einen
Mechanismus. Zurückstellen entfernt das Posteingang-Label und hängt LabelRole::Snoozed an; es
schreibt keine Statusspalte und blendet danach Zeilen aus. Zwei Dinge folgen daraus gratis: Die
Invariante „eine Nachricht trägt mindestens ein Label" hält ohne einen zweiten Mechanismus, und
der Stapel der zurückgestellten Konversationen ist über jeden Weg erreichbar, der ohnehin auf
Labels arbeitet — die Seitenleiste, eine Abfrage, der vereinigte Feed.
Die Labeländerung wandert nach außen, genau wie beim Archivieren. Das ist der Zweck und kein Nebeneffekt: Eine zurückgestellte Konversation soll auch in Gmails Posteingang aus dem Weg sein. Die Weitergabe geschieht, bevor die Labels sich lokal bewegen, damit ein IMAP-Job noch den Quellordner sieht.
LabelRole::Snoozed ist die eine Rolle, deren hasProviderFolder() false antwortet — sie hat
kein IMAP-Special-Use und kein Gegenstück beim Anbieter, und MailboxSpecialUse bildet nie auf
sie ab. Ein Push, der sie als Ordner behandelt, kann nur nach einer Id suchen, die nie jemand
gesetzt hat, und eine Nachricht, die sie trägt, sähe für alles, was Orte zählt, so aus, als sei
sie an zwei Stellen zugleich.
wake() markiert die Konversation als ungelesen, und der Lesezustand, den sie hatte, ist
tatsächlich verloren. Das ist ein bewusster Handel: Eine Konversation, die in dem Zustand
zurückkommt, in dem du sie verlassen hast, ist eine, an der du bereits gelernt hast
vorbeizuscrollen.
„Im Papierkorb“ ist gespeichert, und die Datenbank pflegt es
Eine Konversation ist im Papierkorb, wenn sie Trash trägt und keine ihrer Nachrichten außerhalb
von Trash liegt (#268). Jede Liste, jedes Badge und die Suche lassen eine solche Konversation
weg. Sie lesen dafür message_thread.binned, statt die Regel aus den Nachrichten abzuleiten; bei
einem Postfach mit 67k Konversationen kostete das Ableiten etwa 170 ms pro Abfrage.
Die Spalte pflegen Trigger, nicht PHP (Version20261022160000). Die Schreibvorgänge, die die
Antwort ändern, sind verstreut: Labels über das ORM, rohes SQL, das Konversationen neu aufbaut
oder ein Label entfernt, ON DELETE CASCADE, eine Nachricht, die in eine andere Konversation
wandert, eine Antwort, die in einer Konversation im Papierkorb ankommt. Ein Flag, das PHP setzt,
wäre nur auf den Wegen richtig, an die jemand gedacht hat.
message_labelundthread_labelfeuern einmal pro Anweisung und berechnen nur die Konversationen neu, deren Trash-Zeilen sich geändert haben. Ablegen, Lesen und Labeln kosten nichts.messagefeuert pro Zeile: beim Einfügen nur, wenn die Konversation im Papierkorb ist (eine neue Nachricht kann sie dort nur herausholen), beim Löschen und bei geändertemthread_idfür die Konversation, die die Nachricht verlassen hat.- Jede Neuberechnung leitet die Antwort von Grund auf ab (
plmail_refresh_thread_binned()), deshalb spielt die Reihenfolge der Anweisungen in einem Flush keine Rolle.
Die Entity bildet die Spalte nur lesend ab. Im Speicher kann sie einer noch nicht geflushten Änderung hinterherhinken, deshalb sollte PHP nicht darauf verzweigen; die Abfragen lesen sie.
Das Änderungsprotokoll, das JMAP liest
Jede Mail-Änderung schreibt eine Zeile nach jmap_change_log (App\Jmap\State\ChangeLog). Der
autoinkrementierende Primärschlüssel ist das Zustandstoken: Der Zustand eines Clients für
ein Paar (accountId, objectType) ist die höchste dafür verzeichnete Sequenz, und
Email/changes liefert die Zeilen mit sequence > sinceState.
accountId ist mit Absicht ein Skalar und kein ManyToOne — diese Zeilen werden aus
langlaufenden Sync-Handlern geschrieben, und Entity-Referenzen über einen flush() hinweg zu
halten ist dort die dokumentierte Fußangel.
App\Jmap\State\StateManager ist die Fassade, und record() persistiert nur: Es flusht
nie, damit die Protokollzeile in der schon laufenden Unit of Work des Aufrufers committet wird.
Daraus erbt jeder Aufrufer zwei Konsequenzen, und beide haben schon zugebissen:
- Die Ids müssen bereits existieren, ein
record-Aufruf gehört also hinter den Flush, der sie prägt. Eine Nachricht, die vor ihrem Insert verzeichnet wird, verkündet die Id 0. - Hier flusht nichts. Die Protokollzeilen reiten auf dem Flush des Aufrufers hinaus, in derselben Unit of Work wie die Änderung, die sie beschreiben — eine Änderung, die zurückgerollt wird, nimmt ihre Ankündigung also mit.
App\Service\Mail\MailChangeRecorder sitzt obendrauf, weil die entity-freie Form von
StateManager jedem Aufrufer dieselben zwei Dinge zu merken überlässt: welchem Objekttyp das
gerade Geschriebene angehört, und dass eine Email, die sich bewegt hat, auch die Konversation
bewegt hat, in der sie liegt. Davon gab es fünf Kopien, und die, die die Konversation vergaßen,
sahen beim Lesen nicht offensichtlich falsch aus. Es gibt mit Absicht keine Methode pro Feature
— das automatische Speichern eines Entwurfs, ein Anhang und ausgehende Mail sind dieselbe
Ankündigung.
Konversationen werden immer als updated verzeichnet, nie als created: Eine brandneue
Konversation von einer gewachsenen zu unterscheiden hieße zu fragen, ob auch jede einzelne ihrer
Nachrichten neu ist, und RFC 8620 §5.2 verlangt von Clients ohnehin, eine Id aus updated zu
holen, die sie noch nicht haben.
StateManager sammelt außerdem die schmutzigen Paare (account, type) im Speicher, und
JmapPushSubscriber leert diese Sammlung einmal pro Request oder Handler — ein Gmail-Stapel,
der 50 Nachrichten importiert, erzeugt so eine Push-Benachrichtigung statt fünfzig. Siehe
JMAP.
Fallstricke
bodyHtml und bodyHtmlSafe sind beide echt, und keines ist überflüssig. Die bereinigte
Kopie ist das, was gerendert wird; die rohe ist das, was StructuredDataEventExtractor liest,
denn der Block <script type="application/ld+json">, den er braucht, wird aus der sicheren
Fassung entfernt — völlig zu Recht. BodyHtmlPreservesStructuredDataTest nagelt das fest. Die
beiden zusammenzulegen kostet die Extraktion von Terminen aus
Buchungsbestätigungen, und zwar lautlos.
Ungelesen richtet sich nach Message::$seenAt, nicht nach dem IMAP-Flag \Seen. Es gibt
einen Test, der danach benannt ist. Eingehender IMAP-Flag-Sync über den IDLE-Strom ist nicht
implementiert — Flags wandern nur nach außen —, eine Nachricht in einem anderen Client als
gelesen zu markieren spiegelt sich also nicht zurück, und das Anbieter-Flag als Wahrheit zu
lesen ließe die beiden in die verwirrendere Richtung auseinanderfallen.
Eine JMAP-Änderung vor dem Flush zu verzeichnen, der die Id prägt, veröffentlicht die Id 0.
Deshalb kündigt PostIngestPipeline Konversationen in einem zweiten Durchgang an statt in
ihrer Schleife, und deshalb nimmt MailChangeRecorder::emailChanged() überhaupt eine nullbare
Konversation entgegen.
search_vector ist eine generierte Spalte, und kein PHP liest sie. Sie ist rein deshalb als
insertable: false, updatable: false gemappt, damit sie im Schema bleibt;
idx_message_search_vector ist als schlichter Index deklariert und wird von der Migration
USING gin gebaut, weil Doctrines Vergleicher einen Index an Name und Spalten erkennt und die
Methode nie ansieht. Ohne die Deklaration will jeder Schema-Diff den Index löschen —
und der gelöschte Index macht aus der Suche einen sequenziellen Scan, ohne dass irgendetwas
fehlschlägt.
Das Label Archive ist Ortsbuchführung, kein Zustand. „Archiviert" heißt im Domänenmodell
„trägt kein Posteingang-Label"; das Label Archive gibt es, damit die Ortslabel-Invariante auch
für schlichte IMAP-Konten hält, deren Server einen physischen Archivordner hat. Es wird
versteckt angelegt und kann sichtbar gemacht werden — deshalb kann ein Archiv-Eintrag in
jemandes Seitenleiste stehen oder eben nicht.
Ein Post-Ingest-Step, der echte Arbeit tut, bremst jeden Sync auf der Installation. Das Interface dokumentiert „anstoßen, nicht arbeiten", und die Pipeline kann es nicht erzwingen; der Fehler ist keine Fehlermeldung, sondern ein Postfach, das langsamer wird, je mehr Features an einen Haken gehängt werden, der auf dem verbindungshaltenden Worker läuft.
Ein Step, der ohne EnrichmentRouter einreiht, stellt Live-Mail hinter einen Import. Die
Routing-Tabelle schickt jede Post-Ingest-Nachricht auf enrich; nur der Stamp bringt die Mail
eines fertigen Kontos auf enrich_live. Abschicken mit $this->router->stampsFor($result), und
nur für RecentMailPolicy::recentIds().
Eine zurückgehaltene Nachricht, die nichts freigibt, ist Mail, die niemand im Posteingang
sieht. Ihre Konversation hat keine Kategorie und steht damit in keinem Tab. Deshalb wird die
zeitgesteuerte Freigabe in ClassifyMailStep vor der Feature-Prüfung abgeschickt, und deshalb
sichert sie ein Durchlauf auf einem anderen Worker ab.
This page is generated from docs/de/internals/mail-ingest.md. Edit it there — changes made here are overwritten on the next push to main.
plMail
Using plMail
- Accounts and aliases
- Account health
- Templates
- Filters
- Calendar
- Invitations and events from mail
- Reminders
- Connected calendars
- Sharing and booking
- Contacts
- 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