Import an account's history without holding up new mail

A first sync used to be the import: every IMAP folder from its oldest
message to its newest, every Gmail message listed before one was fetched,
every Microsoft folder enumerated to its end, all in one job on the queue
new mail arrives on. A large account showed an empty Inbox for most of an
hour and every other account's mail waited behind it (#42).

The sync now only arranges for new mail to be seen. History is
MailImporter's, a page at a time on a new `ingest_backlog` queue with a
worker of its own, newest mail first and the Inbox before anything else;
Spam and the bin come last. Accounts take turns, a page that fails costs
itself, and every sync restarts an import that has gone quiet.

- IMAP walks each folder down from the top in pages of fifty.
- Gmail asks for the newest 200 of the Inbox, then pages the listing.
- Microsoft reads each folder's first delta round a page at a time; a
  folder being read is asked only what it received since. Removals that
  cannot be judged while a folder is unread are kept and judged later.
- Sync requests a later sync has already covered are skipped.
- A chip beside the search box shows that an import is running and how
  far it has got. The indicator never updated on mail pages, because its
  controller was not mounted there.

Also from #42: an account can have a separate password for sending,
behind a button under the SMTP fields. One field by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-10 23:43:03 +02:00
1 parent bfc2a022e9
commit 896f30eb02
82 files changed
+4961 -189

No files matched your search

+47
View File
@@ -8,6 +8,53 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**A new account shows its newest mail first, and no longer holds up everybody else's.** Adding an
account used to bring its whole history in through the sync itself: every IMAP folder from its
oldest message to its newest, or every Gmail message or Microsoft folder listed before one message
was fetched, in one job on the queue all new mail arrives on. A large account showed an empty
Inbox for most of an hour, new mail on every other account waited behind it, and the worker doing
it was shown as dead.
[#42](https://github.com/karatektus/pl_mail/issues/42).
- **Newest first, Inbox first.** The most recent mail of the Inbox arrives within about a minute,
then the rest of it, then Sent, Drafts and your own folders, the archive, and Spam and the bin
last. Several accounts added together take turns, so each has a usable Inbox early.
- **Its own queue and worker.** History is imported on `ingest_backlog` by a new `worker-backlog`
process, a page at a time. Mail arriving now stays on `ingest` and is never behind it. The
worker starts by itself; there is nothing to configure.
- **You can see it.** A chip beside the search box says **Importing** and how far along it is
while an import runs. It opens a list of each importing account with how much is in and which
folder it is on, and says so when an import is waiting.
- **Repeated sync requests collapse.** The quarter-hour poll, a push and the Sync button used to
queue a full sync each, one behind the other. A request a later sync has already covered is
skipped.
- **One message that will not import costs itself.** It is retried with its page five times and
then passed over, with an error in the log, instead of stopping its folder.
This covers IMAP, Gmail and Microsoft accounts alike. Folders that had already synced are
untouched; a folder that was in the middle of its first sync when you upgrade is picked up again
from the top, and what it had stored is not fetched twice. One migration adds four columns to the
folder table.
Two things change for Microsoft accounts beyond the import. A folder whose sync state expired on
Microsoft's side used to be read again from end to end inside the sync; it is handed to the import
instead. And a message deleted in Outlook while another folder's sync was failing is now removed
here once that folder answers, where it used to stay behind: in no list, and counted in every
total.
**The Background work button updates while you watch it.** It never did on a mail page: the
controller that refreshes it was not loaded there, so a bulk action's progress showed whatever it
had reached when the page was drawn. Where it was loaded, it refreshed once and then not again,
and a refresh shut the open list. All three are fixed.
**A separate password for sending, for the servers that want one.** The account form has one
password field, as before, and under the SMTP settings a new **Use a different password for
sending**, which reveals a second. Zoho issues an app password per protocol, and an account set up
with one of them synced and then failed every send on authentication, with nowhere to enter the
other. The username stays shared. The connection test uses both, and a config backup carries both.
One migration adds a column to the account table; it runs by itself and rewrites nothing.
[#42](https://github.com/karatektus/pl_mail/issues/42).
**One message could stop an IMAP folder from syncing, for good.** A multipart message whose
header puts spaces around the equals sign — `boundary = "…"`, which the standard allows and some
mailers write — could not be read, and because that failed while a page of mail was being fetched,
+41 -1
View File
@@ -14,6 +14,10 @@ import { Controller } from "@hotwired/stimulus";
* percent. A trailing reload keeps the last one, so the FINAL state — the one
* that says done, or failed — always lands.
*/
/** The list of work inside the indicator, and the button that opens it. */
const MENU = "[data-ui--dropdown-target='menu']";
const TOGGLE = "[data-action~='click->ui--dropdown#toggle']";
export default class extends Controller {
static values = { url: String };
@@ -54,10 +58,46 @@ export default class extends Controller {
return;
}
// Read before the frame is replaced: whether somebody has the list
// open and is watching it.
const wasOpen = null !== frame.querySelector(`${MENU}:not([hidden])`);
if (true === wasOpen) {
// The answer is a whole new frame, dropdown included, and a new
// dropdown is a closed one. To somebody watching a count go up,
// that was the list shutting in their face every second and a
// half — which is the same as it not working. Opened again as soon
// as the new one is in, on the next frame so its controller has
// connected and there is something to hear the click.
frame.addEventListener("turbo:frame-load", () => {
requestAnimationFrame(() => {
const menu = frame.querySelector(MENU);
if (null !== menu && true === menu.hidden) {
// No entrance the second time: it never left.
menu.setAttribute("data-enter", "none");
frame.querySelector(TOGGLE)?.click();
}
});
}, { once: true });
}
// The frame carries no src — one on the page would fetch itself on
// every load, for every user, to say nothing is happening. So the URL
// is set here, at the one moment there is something to look at, and
// Turbo fetches it because assigning src is what triggers that.
frame.src = this.urlValue;
//
// ONCE. Assigning the same URL again changes nothing, and Turbo
// fetches on a change: the first nudge of a page's life updated the
// indicator and every one after it did nothing at all, so a job
// showed whatever it had reached when it was first looked at and then
// sat there. From the second nudge on the frame is told to reload.
if (null === frame.getAttribute("src")) {
frame.src = this.urlValue;
return;
}
frame.reload();
}
}
@@ -64,6 +64,17 @@ export default class extends Controller {
smtpEncryption: this._value("smtpEncryption"),
};
// A second password for sending, only while the form is asking for
// one — see settings--smtp-password. Sent as a flag and a value, like
// the form itself: a blank value with the flag set means "the stored
// one" on the edit form.
const separate = this.element.querySelector('[name$="[separateSmtpPassword]"]');
if (null !== separate && true === separate.checked) {
payload.separateSmtpPassword = true;
payload.smtpPassword = this._value("smtpPassword");
}
if (this.hasAccountIdValue === true) {
payload.accountId = this.accountIdValue;
}
@@ -0,0 +1,60 @@
import { Controller } from "@hotwired/stimulus";
/**
* One password field for an account, and a second for sending only when asked.
*
* Nearly every mail server takes the same password to read and to send, so the
* account form shows one. A few do not (Zoho issues an app password per
* protocol), and for those a button reveals a second field.
*
* What it switches is a checkbox the form renders and hides. The server reads
* the box, not the field: see AccountType. So this never has to decide what a
* blank field means — it says whether a second password is wanted, and the
* form's own rules do the rest.
*
* The state is read from the box on connect rather than assumed closed: an
* account that already has a sending password opens with the field showing,
* and a form that came back from a failed validation keeps it open.
*
* Targets:
* separate — the checkbox: ticked while a second password is wanted
* offer — the button that asks for one; hidden while the field shows
* fields — the second password and the way back; hidden otherwise
* password — the second password's input, emptied on the way back
*/
export default class extends Controller {
static targets = ["separate", "offer", "fields", "password"];
connect() {
this.#show(true === this.separateTarget.checked);
}
separate() {
this.separateTarget.checked = true;
this.#show(true);
if (true === this.hasPasswordTarget) {
this.passwordTarget.focus();
}
}
same() {
this.separateTarget.checked = false;
// Emptied, although the server would ignore it with the box clear: a
// password left sitting in a field nobody can see is the kind of thing
// that gets submitted somewhere it was not meant for.
if (true === this.hasPasswordTarget) {
this.passwordTarget.value = "";
}
this.#show(false);
}
// ── Private ───────────────────────────────────────────────────────────
#show(separate) {
this.fieldsTarget.hidden = false === separate;
this.offerTarget.hidden = separate;
}
}
+38
View File
@@ -58,6 +58,42 @@ framework:
max_retries: 5
max_delay: 300000
# A mailbox being brought in for the first time, a page at a time.
#
# ITS OWN QUEUE AND ITS OWN WORKER, for the reason `ingest` has one
# apart from `export`: who is waiting. `ingest` is mail arriving now
# — a push from Gmail, an IDLE notice, somebody pressing Sync. This
# is twenty thousand messages from 2019 that nobody is watching
# individually. On one queue the second was simply in front of the
# first: an account added on Friday put every new mail of every
# account behind its history, in arrival order, for as long as the
# history took (#42).
#
# A second transport on worker-ingest would not have done. Messenger
# looks at an earlier transport before a later one, but only BETWEEN
# messages — a worker inside a page of history cannot hear that new
# mail has arrived. Two processes can.
#
# Everything on it is bounded: one page, then a message asking for
# the next. That is what lets several accounts import side by side
# (their pages interleave in arrival order, so each gets a usable
# Inbox before any gets its archive) and what keeps this worker's
# heartbeat fresh while it works.
#
# Reached by TransportNamesStamp for the Gmail and Graph batches,
# which are the same message live or imported and only the code
# dispatching them knows which; ImportMailMessage is routed below.
ingest_backlog:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: ingest_backlog
# The ingest ladder: the failures are the same third parties.
retry_strategy:
delay: 5000
multiplier: 3
max_retries: 5
max_delay: 300000
# What follows mail arriving, and none of it is fetching.
#
# THREE QUEUES (and `release` above them), BECAUSE `ingest` USED TO HOLD ALL OF THIS AND THE
@@ -287,6 +323,8 @@ framework:
# the cheap one — a point lookup and a flag write, no IMAP at all.
App\Infrastructure\Messaging\Message\ApplyRemoteFlagsMessage: ingest
App\Infrastructure\Messaging\Message\SyncAccountMessage: ingest
# One page of a mailbox's history, and the request for the next.
App\Infrastructure\Messaging\Message\ImportMailMessage: ingest_backlog
App\Infrastructure\Messaging\Message\SendMessageMessage: export
App\Infrastructure\Messaging\Message\ApplyImapFlagsMessage: export
# Onto export with every other outbound push, and that placement is
+12 -3
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/accounts.md sha1:dd31f87ab1777ab4166251dc0b7cb0ce6aef417f -->
<!-- translated-from: features/accounts.md sha1:0a9e8d4f75f2ed1058799d55ad90b09e851c653b -->
# Konten und Aliase
@@ -30,7 +30,16 @@ daran, aber ein Konto, das sauber abgelegt wird und sich nicht anmelden kann, is
Fehler, den man lieber jetzt als bei der ersten Synchronisierung findet — deshalb läuft die
Prüfung beim Speichern noch einmal, und ihr Ergebnis bleibt am Konto vermerkt.
Die erste Synchronisierung startet sofort. Danach hält plMail eine IMAP-IDLE-Verbindung zu
Die erste Synchronisierung startet sofort, und die vorhandene Mail des Kontos wird danach
hereingeholt, die neueste zuerst: das Jüngste aus dem Posteingang binnen etwa einer Minute, dann
der Rest des Posteingangs, dann Gesendet, Entwürfe und deine eigenen Ordner, das Archiv und
zuletzt Spam und Papierkorb. Mail, die währenddessen ankommt, wird davon nicht aufgehalten. Neben
dem Suchfeld steht **Import läuft**, solange ein Konto importiert — ein Klick darauf zeigt den
Stand je Konto und den Ordner, an dem es gerade ist; bewegt sich eine Weile nichts, steht dort
**Wartet darauf, weiterzumachen**, und ein Konto, das nicht erreichbar ist, erscheint unter
**Zustand der Konten**.
Danach hält plMail eine IMAP-IDLE-Verbindung zu
jedem Postfach und synchronisiert in dem Moment, in dem sich etwas ändert, mit einem geplanten
Durchlauf alle fünfzehn Minuten dahinter.
@@ -54,7 +63,7 @@ Google abgelehnt werden und beim nächsten Abgleich wieder verschwinden. Ein Mic
dieselben Rechte per Richtlinie zurückhalten.
plMail vergleicht jetzt, was es bekommen hat, mit dem, wonach es gefragt hat, und zeigt bei einem
Unterschied eine Karte unter **Kontozustand** — mit einem Knopf, um das Konto neu zu verbinden und
Unterschied eine Karte unter **Zustand der Konten** — mit einem Knopf, um das Konto neu zu verbinden und
den Rest zu erteilen. Wenn du ein Konto verbunden hast, bevor es das gab, erscheint die Karte beim
ersten Erneuern der Anmeldung oder sobald eine Änderung zum ersten Mal abgelehnt wird.
+3 -2
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/docker.md sha1:6a1b5ac4bb8a1c96bf13c943ef14a18ab8ce0cbf -->
<!-- translated-from: install/docker.md sha1:77ef18f80d56bea9e0c01c3223e7a745282137ee -->
# Installation mit Docker Compose
Der unterstützte Weg, von Anfang bis Ende: was du brauchst, was `docker compose up` tatsächlich tut,
@@ -156,7 +156,8 @@ bis zu dreißig Sekunden, wenn er abgestürzt ist.
| `mercure` | Der Mercure-Hub, 1.x | Live-Aktualisierungen — die Mailliste, die sich von selbst auffrischt |
| `imap-supervisor` | `app:imap:supervise` | Startet und überwacht je einen `app:imap:idle`-Prozess pro IDLE-fähigem Postfach, damit gewöhnliche IMAP-Mail in dem Moment ankommt, in dem sie eintrifft |
| `worker-export` | `messenger:consume export` | Alles, was plMail verlässt, und die einzige Warteschlange, auf die jemand wartet. In einem eigenen Prozess, damit ein Versand nie hinter einem Abgleich steht |
| `worker-ingest` | `messenger:consume ingest` | Mail abholen, und nur das |
| `worker-ingest` | `messenger:consume ingest` | Mail abholen, die gerade ankommt, und nur das |
| `worker-backlog` | `messenger:consume ingest_backlog` | Die Vorgeschichte eines Postfachs beim ersten Import, Seite für Seite. In einem eigenen Prozess, damit gerade ankommende Mail nie dahinter steht |
| `worker-live` | `messenger:consume enrich_live` | Was auf Mail folgt, die gerade eben angekommen ist — Einsortieren, Terminerkennung, Insight-Karten, Lesebestätigungen. Eigener Prozess, damit neue Mail nie hinter einem Import steht |
| `worker-release` | `messenger:consume release` | Zeigt Mail an, die für den Assistenten zurückgehalten wurde, sobald ihre Wartezeit vorbei ist. Fast immer untätig, und in einem eigenen Prozess, damit nie etwas davor steht |
| `worker-enrich` | `messenger:consume enrich enrich_backlog` | Dieselbe Arbeit für die aktuelle Mail eines Imports, danach — nur wenn diese Warteschlange leer ist — alte Mail, die der Assistent einsortiert |
+3 -2
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/troubleshooting.md sha1:6776a1d8e46bcf99b043bdac37a1154c27c04abb -->
<!-- translated-from: install/troubleshooting.md sha1:ad52dfe87905d09cdd1f0545fabb06f413d402fa -->
# Fehlersuche
Was `/healthz` bedeutet, wie du eine hängende von einer leeren Warteschlange unterscheidest, wo die
@@ -60,7 +60,8 @@ Worker-Container:
| Warteschlange | Prozess | Was darauf liegt |
|---|---|---|
| `export` | `worker-export` | Alles, was plMail verlässt — Versand, Flag-Pushes, Gmail-Label-Änderungen, Mail aus dem Notifier. Die einzige Warteschlange, auf die jemand wartet |
| `ingest` | `worker-ingest` | Mail abholen und sonst nichts: Konto- und Postfachabgleiche, Gmail- und Graph-Nachrichten-Batches, Kalenderabgleiche |
| `ingest` | `worker-ingest` | Mail abholen, die gerade ankommt, und sonst nichts: Konto- und Postfachabgleiche, Gmail- und Graph-Nachrichten-Batches, Kalenderabgleiche |
| `ingest_backlog` | `worker-backlog` | Ein erster Import: Seiten aus der Vorgeschichte eines Postfachs, die neuesten zuerst. Lang, solange ein großes Konto hereingeholt wird, und so ist es gedacht — nichts, was gerade ankommt, wartet darauf |
| `enrich_live` | `worker-live` | Was auf Mail folgt, die gerade eben in einem Konto angekommen ist, dessen erster Import abgeschlossen ist: Einsortieren durch den Assistenten, Terminerkennung, Insight-Karten, Lesebestätigungen. Eigener Prozess, damit neue Mail nie hinter einem Import steht |
| `release` | `worker-release` | Beendet das Warten für Mail, die für den Assistenten zurückgehalten wird. Fast immer leer; eigener Prozess, damit nie etwas davor steht |
| `enrich` | `worker-enrich` | Dieselbe Arbeit für aktuelle Mail aus einem Import, dazu die Suchindizierung |
+2 -2
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/architecture.md sha1:6dbabd36bda20eb3426c8d4237e129b868d776f6 -->
<!-- translated-from: internals/architecture.md sha1:9a71407f7557ff10c7e390efae4605835733b595 -->
# Architektur
Die Schichten, was wo liegt, und die Regeln, die das so halten. Diese Seite beschreibt die
@@ -177,7 +177,7 @@ Workers, damit ein verzögerter Retry nie auf einen Neustart warten muss.
Getrennte Transports allein genügen nicht. Ein Worker, der bereits in einem langen Handler
steckt, kann eine Sendung nicht aufnehmen, wie hoch sie auch priorisiert ist — also hat jeder
Transport seinen eigenen Prozess: `worker-export`, `worker-ingest`, `worker-live`, `worker-release`, `worker-enrich`,
Transport seinen eigenen Prozess: `worker-export`, `worker-ingest`, `worker-backlog`, `worker-live`, `worker-release`, `worker-enrich`,
`worker-maintenance` und `worker-bulk`, die `app:work` nebeneinander im Worker-Container betreibt.
`worker-enrich` ist der eine, der zwei Transports abarbeitet, `enrich` und `enrich_backlog`, in
dieser Reihenfolge — siehe [Mail-Ingest](mail-ingest.md#was-auf-eine-nachricht-folgt-und-wo-es-läuft). Ein fünfter Transport, `async`, wird ohne Routing
+65 -5
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/mail-ingest.md sha1:38b6beb3ba071c7ee0ef05200adc575b44fdf9c7 -->
<!-- translated-from: internals/mail-ingest.md sha1:dcb2b781f03c1702974cc382857086d18024e21c -->
# Mail-Ingest
Vom Anbieter in die Datenbank: wie aus Bytes eine Zeile `Message` wird, was mit ihr geschieht,
@@ -43,8 +43,10 @@ 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, sodass ein Codepfad den
Erst- und den Folgefall abdeckt.
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
@@ -61,6 +63,63 @@ veraltetem Stand. Was tatsächlich hält, wenn Stapel sich über Läufe oder Ret
ü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.** `ImportMailMessage` trä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 `ImapImportPlan`
`lastSeenUid` an das obere Ende des Ordners und `importFloorUid` direkt darüber. Neue Mail
ist alles oberhalb des ersten; Vorgeschichte ist alles unterhalb des zweiten, und
`ImapMailboxImporter` arbeitet 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.
- **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, und `GraphApiSyncer::importPage()` geht diese Runde in Schritten von
fünfzig Nachrichten durch, die neuesten zuerst, in derselben Ordnerreihenfolge nach
`LabelRole::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
@@ -156,8 +215,9 @@ Regeln der Nutzerin, ihre Kontakte — und keinen Job. Die Backfill-Aufgaben (`a
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 überhaupt einen Delta-Link
gibt, IMAP darüber, ob jeder zum Abgleich aktivierte Ordner ein `syncedAt` hat. Die Pipeline
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
+7 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: providers/imap-smtp.md sha1:fd8e43c65cd158f4e298cdf3ea0384299bba5d68 -->
<!-- translated-from: providers/imap-smtp.md sha1:90f0e331560ca2cd562d56cfc01609f1a52cb275 -->
# IMAP und SMTP
@@ -32,6 +32,12 @@ Anbieter tatsächlich uneins sind.
5. **Ausgehend — SMTP** nimmt dieselben drei, und alles davon ist optional. Ein Postfach ohne
SMTP-Einstellungen ist ein Postfach, das du lesen und aus dem du nicht senden kannst, und das ist
für ein Archiv ein legitimer Wunsch.
Das eine Passwort gilt für beide Richtungen. Wo der Server zum Senden ein anderes verlangt —
Zoho vergibt je Protokoll ein eigenes App-Passwort, und manche Anbieter stellen ein Relay mit
eigenen Zugangsdaten vor ihr SMTP —, blendet **Zum Senden ein anderes Passwort verwenden** unter
den SMTP-Feldern ein zweites Feld ein. Der Benutzername bleibt derselbe. **Zum Senden dasselbe
Passwort verwenden** nimmt es wieder weg.
6. **Verbindung testen** prüft IMAP und SMTP getrennt und meldet beides einzeln, ein funktionierendes
Postfach mit falschem SMTP-Port sagt das also, statt beim ersten Senden zu scheitern.
+9 -1
View File
@@ -24,7 +24,15 @@ which is what nearly every provider wants.
but an account that stores cleanly and cannot log in is the failure worth catching now rather than
at the first sync, so the probe runs again on save and its outcome is remembered on the account.
The first sync starts immediately. After that, plMail holds an IMAP IDLE connection to each
The first sync starts immediately, and the account's existing mail is then brought in newest
first: the most recent of the Inbox within a minute or so, the rest of the Inbox after it, then
Sent, Drafts and your own folders, the archive, and Spam and the bin last. Mail that arrives
while this runs is not held up by it. A chip beside the search box says **Importing** for as long
as an account is, and opens a list with how much of each is in and which folder it is on; if nothing
has moved for a while it says **Waiting to continue**, and an account that cannot be reached is
listed under **Account health**.
After that, plMail holds an IMAP IDLE connection to each
mailbox and syncs the moment something changes, with a scheduled sweep every fifteen minutes
behind it.
+2 -1
View File
@@ -141,7 +141,8 @@ crashed.
| `mercure` | The Mercure hub, 1.x | Live updates — the mail list refreshing by itself |
| `imap-supervisor` | `app:imap:supervise` | Spawns and watches one `app:imap:idle` process per IDLE-enabled mailbox, so standard IMAP mail arrives the moment it lands |
| `worker-export` | `messenger:consume export` | Anything leaving plMail, and the only queue somebody is watching. On its own process so a send is never behind a sync |
| `worker-ingest` | `messenger:consume ingest` | Fetching mail, and only that |
| `worker-ingest` | `messenger:consume ingest` | Fetching mail that is arriving now, and only that |
| `worker-backlog` | `messenger:consume ingest_backlog` | A mailbox's history on first import, a page at a time. On its own process so mail arriving now is never behind it |
| `worker-live` | `messenger:consume enrich_live` | What follows mail that has just arrived — sorting, event extraction, insight cards, receipts. On its own process so new mail is never behind an import |
| `worker-release` | `messenger:consume release` | Shows mail that was held for the assistant once its wait is over. Idle nearly always, and on its own process so that nothing is ever in front of it |
| `worker-enrich` | `messenger:consume enrich enrich_backlog` | The same work for an import's recent mail, then — only when that queue is empty — old mail being sorted by the assistant |
+2 -1
View File
@@ -54,7 +54,8 @@ Eight transports on the Doctrine transport, consumed by seven processes in the w
| Queue | Process | What is on it |
|---|---|---|
| `export` | `worker-export` | Anything leaving plMail — sends, flag pushes, Gmail label changes, mail from the notifier. The only queue somebody is waiting on |
| `ingest` | `worker-ingest` | Fetching mail and nothing else: account and mailbox syncs, Gmail and Graph message batches, calendar syncs |
| `ingest` | `worker-ingest` | Fetching mail that is arriving now and nothing else: account and mailbox syncs, Gmail and Graph message batches, calendar syncs |
| `ingest_backlog` | `worker-backlog` | A first import: pages of a mailbox's history, newest first. Long while a large account is being brought in, and that is the design — nothing arriving now waits on it |
| `enrich_live` | `worker-live` | What follows mail that has just arrived on an account whose first import is over: sorting by the assistant, event extraction, insight cards, read receipts. Its own process, so new mail is never behind an import |
| `release` | `worker-release` | Ends the wait for mail held for the assistant. Nearly always empty; its own process so nothing is ever in front of it |
| `enrich` | `worker-enrich` | The same work for recent mail brought in by an import, and search indexing |
+1 -1
View File
@@ -163,7 +163,7 @@ delayed retry is never left waiting on a restart.
Separate transports are not enough on their own. A worker already inside a long handler
cannot pick up a send however it is prioritised, so each transport has its own process:
`worker-export`, `worker-ingest`, `worker-live`, `worker-release`, `worker-enrich`, `worker-maintenance` and
`worker-export`, `worker-ingest`, `worker-backlog`, `worker-live`, `worker-release`, `worker-enrich`, `worker-maintenance` and
`worker-bulk`, which `app:work` runs side by side in the worker container. `worker-enrich` is the one
that takes two transports, `enrich` and `enrich_backlog`, in that order — see
[Mail ingest](mail-ingest.md#what-follows-a-message-and-where-it-runs). A
+55 -3
View File
@@ -38,7 +38,9 @@ to strand an account on whatever the first run happened to fetch.
**Graph has no account-level cursor.** There is no equivalent of a single `historyId`, so
delta state is per folder, stored as a `folderId => deltaLink` map on the `Account`. A delta
query with no stored link enumerates the folder and hands back one; the same call with a link
returns only changes, so one code path covers both the initial and the incremental case.
returns only changes. To Graph that is one operation. To plMail it is two: following a folder
that has a link is the sync's, and the enumeration that earns the link is the import's — see
"A first import is not a sync" below.
Graph's folder moves are the sharp edge. Moving a message out of a folder appears as
`@removed` on the source folder's delta and as an addition on the destination's, and the two
@@ -54,6 +56,56 @@ The guard that actually holds when batches overlap across runs or retries is
`uniq_message_gmail_id_account` and its Graph counterpart on `message`, with the provider id
leading the column list so the index also serves the id-only lookups.
### A first import is not a sync
A sync used to bring in an account's history as well as its new mail: one job that read every
IMAP folder from its oldest message to its newest, or listed every Gmail message id before
fetching one. On an account of twenty thousand messages that job held the `ingest` queue for
as long as it took, showed an empty Inbox for most of it, and put every other account's new
mail behind it.
The two are separate now. A sync arranges for new mail to be seen and stops; the history is
`App\Service\Mail\MailImporter`'s, on the `ingest_backlog` queue with a worker of its own.
- **One message is one page.** `ImportMailMessage` carries an account id and nothing else; its
handler brings in one page and dispatches the next. There is no long job anywhere in it, so
several accounts' pages interleave on the queue and each gets a usable Inbox before any gets
its archive.
- **IMAP reads each folder from the top down.** On first contact `ImapImportPlan` puts
`lastSeenUid` at the top of the folder and `importFloorUid` just above it. New mail is
everything above the first; history is everything below the second, and
`ImapMailboxImporter` walks that down fifty messages at a call. A page is found by asking
the server which UIDs exist below the floor and taking out the ones already stored, so a
message that is here is never fetched twice.
- **Folders are read in the order somebody would ask for:** Inbox, Sent, Drafts, a person's own
folders, the archive, and Spam and the bin last. `MailboxSpecialUse::importRank()` is the
rule.
- **Gmail asks for the Inbox by name first,** then pages the whole-mailbox listing one request
at a time, with the page token kept on the account between jobs.
- **Microsoft reads each folder's first delta round a page at a time.** A folder without a
delta link is planned on the account (`Account::$graphImport`) instead of being enumerated
by the sync, and `GraphApiSyncer::importPage()` walks that round fifty messages at a call,
newest first, in the same folder order by `LabelRole::importRank()`. The page that ends the
round carries the folder's delta link, and from then on the sync follows it. Until then the
sync asks such a folder only for what it received since it was planned. A delta link that
expires hands its folder back to the import rather than being re-enumerated in the sync.
- **A message that will not store costs itself.** The floor stays where it is and the page is
asked for again; after five tries the floor moves past it and the folder goes on.
Every ordinary sync ends in `MailImporter::ensureRunning()`, which starts an import that has
work and has gone quiet for five minutes. That is what starts a new account's import and what
restarts one whose chain of messages broke.
"Is this account still importing" is `InitialImportState`, and for IMAP it is read off the
folders' `importFloorUid`: a folder's `syncedAt` no longer says so, because its first sync now
ends at once with the history still to come. A Microsoft account is importing for as long as
`Account::$graphImport` lists a folder.
One thing a Microsoft import changes about the sync: a message removed from one folder is a
deletion only if it arrived in no other, and a folder still being read cannot say. Such
removals are kept on the account (`Account::$graphRemovals`) and judged by the first sync
whose every folder answered.
## The post-ingest pipeline
Once a batch of `Message` rows exists and has been flushed, `App\Service\Mail\PostIngestPipeline`
@@ -137,8 +189,8 @@ tasks (`app:backfill insights`, `event-extraction`, …) are how history is read
wants it.
**Live.** `App\Service\Mail\InitialImportState` says whether an account is still bringing in its
mailbox for the first time: Gmail by `needsBackfill()`, Graph by having a delta link at all,
IMAP by every sync-enabled folder having a `syncedAt`. The pipeline asks once per batch and
mailbox for the first time: Gmail by `needsBackfill()`, Graph by having a delta link and no folder
left to read, IMAP by no sync-enabled folder having history still to fetch. The pipeline asks once per batch and
carries the answer on `PostIngestResult::$live`; `EnrichmentRouter` turns it into a
`TransportNamesStamp`. Mail of a finished account goes to `enrich_live`, whose worker never has
import work in front of it. It errs towards "still importing" — a Gmail backfill settles up to
+5
View File
@@ -28,6 +28,11 @@ connection to come up, and about the handful of settings providers genuinely dis
5. **Outgoing — SMTP** takes the same three, and all of it is optional. A mailbox with no SMTP
settings is a mailbox you can read and not send from, which is a legitimate thing to want for an
archive.
The one password is used for both directions. Where the server wants a different one to send —
Zoho issues an app password per protocol, and some hosts put a relay with its own credentials in
front of SMTP — **Use a different password for sending** under the SMTP fields reveals a second
field. The username stays shared. **Use the same password for sending** takes it away again.
6. **Test connection** probes IMAP and SMTP separately and reports each one, so a working mailbox
with a wrong SMTP port says so instead of failing at the first send.
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* A separate password for sending, for the accounts whose server wants one.
* See App\Entity\Mail\Account::$smtpPassword.
*
* Nullable and left null on every existing row, which is the meaning it
* already has: one password, used for both. Nothing is backfilled because
* nothing existing has two.
*
* TEXT like `password` beside it — the value is encrypted before it is
* stored, and the ciphertext is longer than what was typed.
*/
final class Version20261010210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add account.smtp_password for servers that want a different password to send';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE account ADD smtp_password TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE account DROP smtp_password');
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Where a folder's first import has got to. See
* App\Entity\Mail\Mailbox::$importFloorUid.
*
* Four columns, and one statement that matters: every folder that has already
* finished a sync is marked as having nothing left to import. Left null it
* would read as "nobody has looked yet", and the first sync after upgrading
* would plan an import of every folder of every account — harmless, since a
* stored message is not fetched twice, but a walk of every UID of every folder
* to find that out.
*
* A folder that was still in its first, oldest-first pass when the upgrade
* landed has no synced_at and stays null. It is planned again from the top,
* newest first, and what it had already stored is skipped.
*/
final class Version20261010220000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the mailbox import markers, and mark every synced folder as imported';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE mailbox ADD import_floor_uid INT DEFAULT NULL');
$this->addSql('ALTER TABLE mailbox ADD import_total INT DEFAULT NULL');
$this->addSql('ALTER TABLE mailbox ADD import_remaining INT DEFAULT NULL');
$this->addSql('ALTER TABLE mailbox ADD import_page_attempts INT DEFAULT 0 NOT NULL');
$this->addSql('UPDATE mailbox SET import_floor_uid = 0 WHERE synced_at IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mailbox DROP import_floor_uid');
$this->addSql('ALTER TABLE mailbox DROP import_total');
$this->addSql('ALTER TABLE mailbox DROP import_remaining');
$this->addSql('ALTER TABLE mailbox DROP import_page_attempts');
}
}
+4 -2
View File
@@ -6,6 +6,7 @@ namespace App\Controller\Mail;
use App\Entity\User\User;
use App\Repository\Job\BackgroundJobRepository;
use App\Service\Mail\ImportProgress;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -28,10 +29,11 @@ final class JobsController extends AbstractController
{
#[Route('/mail/jobs/indicator', name: 'app_jobs_indicator', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function indicator(#[CurrentUser] User $user, BackgroundJobRepository $jobs): Response
public function indicator(#[CurrentUser] User $user, BackgroundJobRepository $jobs, ImportProgress $imports): Response
{
return $this->render('mail/_jobs_indicator.html.twig', [
'jobs' => $jobs->findVisibleForUser($user),
'jobs' => $jobs->findVisibleForUser($user),
'imports' => $imports->forUser($user),
]);
}
}
+20 -3
View File
@@ -275,8 +275,18 @@ final class AccountController extends AbstractController
$account->smtpPort = (int) ($payload['smtpPort'] ?? 587);
$account->smtpEncryption = (string) ($payload['smtpEncryption'] ?? 'starttls');
// Blank password on the edit form means "keep the stored one".
if ('' === $account->password && null !== ($payload['accountId'] ?? null)) {
// A second password for sending, only when the form says it wants one
// — the same flag-then-value the form itself submits. See AccountType.
$separateSmtp = true === ($payload['separateSmtpPassword'] ?? false);
if (true === $separateSmtp) {
$account->smtpPassword = (string) ($payload['smtpPassword'] ?? '');
}
// Blank on the edit form means "keep the stored one", for either.
$needsStored = '' === $account->password || (true === $separateSmtp && '' === $account->smtpPassword);
if (true === $needsStored && null !== ($payload['accountId'] ?? null)) {
$existing = $this->accountRepository->find((int) $payload['accountId']);
if (null === $existing) {
@@ -284,7 +294,14 @@ final class AccountController extends AbstractController
}
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $existing);
$account->password = $existing->password;
if ('' === $account->password) {
$account->password = $existing->password;
}
if (true === $separateSmtp && '' === $account->smtpPassword) {
$account->smtpPassword = $existing->smtpPassword;
}
}
if ('' === $account->username || '' === $account->imapHost) {
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO\Mail;
use App\Entity\Label\Label;
use App\Entity\Mail\Account;
use App\Entity\Mail\Mailbox;
/**
* How far one account's first import has got, for the person it belongs to.
*
* Not a BackgroundJob, although it is shown in the same indicator. A job row
* is a record of something somebody started, and it is reaped as abandoned
* when it stops moving for a quarter of an hour — which an import does as a
* matter of course, between Gmail listings or while a provider is throttling.
* So there is no row to go stale: this is worked out, each time it is asked
* for, from the account and its folders, which are what the import itself
* reads to know where it is. See ImportProgress.
*/
final readonly class ImportLine
{
/**
* @param int|null $total how many messages the import set out to
* fetch, or null while that is not known yet
* @param Mailbox|null $folder the folder being read, where the provider
* has folders
* @param Label|null $label the same, for a provider whose folders are
* labels and have no Mailbox row
* @param bool $waiting nothing has moved for a while: between two
* listings, or because something is wrong —
* the account's own health says which
*/
public function __construct(
public Account $account,
public int $done,
public ?int $total,
public ?Mailbox $folder,
public bool $waiting,
public ?Label $label = null,
) {}
/** Whole percent, floored and never past 100; 0 until there is a total. */
public function percent(): int
{
if (null === $this->total || 0 >= $this->total) {
return 0;
}
return (int) floor(min(100, ($this->done / $this->total) * 100));
}
}
+21
View File
@@ -91,6 +91,27 @@ enum LabelRole: string
};
}
/**
* Where a folder of this role stands in the order an import reads them.
*
* MailboxSpecialUse::importRank() is the order and the reasons; this is
* the same question asked of a provider that has labels where IMAP has
* mailboxes. Static and taking null for the same reason: the commonest
* folder is one somebody made, and has no role.
*/
public static function importRank(?self $role): int
{
return MailboxSpecialUse::importRank(match ($role) {
self::Inbox => MailboxSpecialUse::INBOX,
self::Sent => MailboxSpecialUse::SENT,
self::Drafts => MailboxSpecialUse::DRAFTS,
self::Archive => MailboxSpecialUse::ARCHIVE,
self::Spam => MailboxSpecialUse::JUNK,
self::Trash => MailboxSpecialUse::TRASH,
default => null,
});
}
public static function fromSpecialUse(MailboxSpecialUse $specialUse): self
{
return match ($specialUse) {
+40 -13
View File
@@ -1,13 +1,40 @@
<?php
namespace App\Domain\Enum\Mail;
enum MailboxSpecialUse: string
{
case INBOX = '\\Inbox';
case SENT = '\\Sent';
case TRASH = '\\Trash';
case DRAFTS = '\\Drafts';
case JUNK = '\\Junk';
case ARCHIVE = '\\Archive';
}
<?php
namespace App\Domain\Enum\Mail;
enum MailboxSpecialUse: string
{
case INBOX = '\\Inbox';
case SENT = '\\Sent';
case TRASH = '\\Trash';
case DRAFTS = '\\Drafts';
case JUNK = '\\Junk';
case ARCHIVE = '\\Archive';
/**
* Where a folder stands in the queue when an account's history is being
* brought in: lower is sooner.
*
* The order somebody would ask for if asked. The Inbox is what they open;
* Sent and Drafts are what they wrote; their own folders (no special use,
* hence the nullable argument) are where they filed things on purpose.
* The archive comes after those because on Gmail over IMAP "All Mail" is
* every message a second time, and by then most of it is already here.
* Spam and the bin are last: nobody is waiting for either.
*
* Static and taking null, rather than a method on a case, because the
* commonest folder has no case at all.
*/
public static function importRank(?self $use): int
{
return match ($use) {
self::INBOX => 0,
self::SENT => 10,
self::DRAFTS => 20,
null => 30,
self::ARCHIVE => 40,
self::JUNK => 80,
self::TRASH => 90,
};
}
}
+246
View File
@@ -89,6 +89,39 @@ class Account extends AccountModel
#[ORM\Column(type: EncryptedStringType::NAME, nullable: true)]
public ?string $password = null;
/**
* A password for sending, where the server wants a different one than for
* reading. Null — which is nearly always — means there is one password and
* it is $password.
*
* Zoho hands out an app password per protocol, and some hosts front their
* SMTP with a relay that has credentials of its own. Until this existed
* such an account could be added and would sync, and every send failed on
* authentication with nothing in the form to put the second password in
* (#42).
*
* Only the password differs. The username is shared: nobody has asked for
* two, and the form stays one field shorter for everyone who has one.
* Read through $sendingPassword, never directly, by anything that sends.
*/
#[ORM\Column(type: EncryptedStringType::NAME, nullable: true)]
public ?string $smtpPassword = null;
/**
* What SMTP authenticates with: the sending password if the account has
* one, the account's password otherwise.
*
* Virtual, and the one place the fallback is written, so a sender cannot
* read $password by habit and work for every account but the ones this
* was added for. An empty string counts as "has none": that is what a
* form field left blank submits.
*/
public ?string $sendingPassword {
get => null !== $this->smtpPassword && '' !== $this->smtpPassword
? $this->smtpPassword
: $this->password;
}
#[ORM\Column(length: 255, nullable: true)]
public ?string $smtpHost = null;
@@ -435,12 +468,33 @@ class Account extends AccountModel
*/
public const string SETTING_BACKFILL_TARGET = 'sync.backfill_target';
/** When the last sync of this account began. See $syncBeganAt. */
public const string SETTING_SYNC_BEGAN_AT = 'sync.began_at';
/** When this account's first import last did something. See $importBeatAt. */
public const string SETTING_IMPORT_BEAT_AT = 'sync.import_beat_at';
/** How many messages the provider says the mailbox holds. See $importTotal. */
public const string SETTING_IMPORT_TOTAL = 'sync.import_total';
/** When the last backfill listing ran, to keep runs from overlapping. */
public const string SETTING_BACKFILL_RAN_AT = 'sync.backfill_ran_at';
/** Where a backfill listing that is under way has got to. See $backfillPageToken. */
public const string SETTING_BACKFILL_PAGE_TOKEN = 'sync.backfill_page_token';
/** Unfetched messages the listing under way has found so far. */
public const string SETTING_BACKFILL_PENDING = 'sync.backfill_pending';
/** Consecutive backfill listings that still found unfetched messages. */
public const string SETTING_BACKFILL_ATTEMPTS = 'sync.backfill_attempts';
/** The Microsoft folders whose history is still to be read. See $graphImport. */
public const string SETTING_GRAPH_IMPORT = 'sync.graph_import';
/** Removals a Microsoft import was not yet able to judge. See $graphRemovals. */
public const string SETTING_GRAPH_REMOVALS = 'sync.graph_removals';
/**
* What this account does when a sender asks for a read receipt, for any
* address that has no answer of its own.
@@ -636,6 +690,198 @@ class Account extends AccountModel
}
}
/**
* The token for the next page of a backfill listing that is under way, or
* null between listings.
*
* A listing is walked a page at a time by MailImporter, each page a job of
* its own, and this is what one job leaves for the next. Null therefore
* means "the next page asked for is the first of a new listing", which is
* when the hourly cooldown is checked.
*/
public ?string $backfillPageToken {
get {
$token = $this->getSetting(self::SETTING_BACKFILL_PAGE_TOKEN);
return is_string($token) && '' !== $token ? $token : null;
}
set (?string $token) {
$this->setSetting(self::SETTING_BACKFILL_PAGE_TOKEN, $token);
}
}
/**
* How many unfetched messages the listing under way has turned up, across
* the pages walked so far. Handed to the settling at the end, which used
* to be given the count of one whole listing made in one go.
*/
public int $backfillPending {
get => max(0, (int) $this->getSetting(self::SETTING_BACKFILL_PENDING, 0));
set (int $pending) {
$this->setSetting(self::SETTING_BACKFILL_PENDING, max(0, $pending));
}
}
/**
* When the last sync of this account began, whether or not it went through.
*
* Beside $lastSyncedAt, which is when one last FINISHED cleanly, because
* the pair answers a question neither can alone: was there a sync that
* started after a given moment and succeeded? See isSyncedSince().
*
* Virtual and a Unix timestamp, like $backfillRanAt.
*/
public ?DateTimeImmutable $syncBeganAt {
get {
$timestamp = $this->getSetting(self::SETTING_SYNC_BEGAN_AT);
return null === $timestamp ? null : (new DateTimeImmutable())->setTimestamp((int) $timestamp);
}
set (?DateTimeImmutable $beganAt) {
$this->setSetting(self::SETTING_SYNC_BEGAN_AT, $beganAt?->getTimestamp());
}
}
/**
* Whether a sync that began after $requestedAt has already gone through.
*
* The test for a redundant sync request. "Began after" is the whole of it:
* a sync that was already running when the request was made may have read
* the server before whatever prompted the request arrived, so its success
* says nothing about it. One that started later has seen it.
*
* Strictly after, to the second. A sync beginning in the same second as
* the request is not counted, which costs one repeated sync now and then
* and never costs mail.
*/
public function isSyncedSince(int $requestedAt): bool
{
$beganAt = $this->syncBeganAt;
return null !== $beganAt
&& $beganAt->getTimestamp() > $requestedAt
&& null !== $this->lastSyncedAt
&& $this->lastSyncedAt >= $beganAt
&& null === $this->lastSyncError;
}
/**
* When this account's first import last did something: planned a folder,
* stored a page.
*
* Two readers. The import's own safety net, which starts a fresh chain of
* pages only when this has gone quiet (see MailImporter::ensureRunning()),
* and the progress line in the topbar, which says "waiting" rather than
* showing a bar that has stopped moving.
*
* Virtual and a Unix timestamp, like $backfillRanAt.
*/
public ?DateTimeImmutable $importBeatAt {
get {
$timestamp = $this->getSetting(self::SETTING_IMPORT_BEAT_AT);
return null === $timestamp ? null : (new DateTimeImmutable())->setTimestamp((int) $timestamp);
}
set (?DateTimeImmutable $beatAt) {
$this->setSetting(self::SETTING_IMPORT_BEAT_AT, $beatAt?->getTimestamp());
}
}
/**
* How many messages the provider says this mailbox holds, as of the moment
* its import was planned. Gmail answers that in one call and Microsoft in
* its folder list; an IMAP account's total is the sum of its folders'
* (Mailbox::$importTotal).
*
* For the progress line and nothing else. It is a snapshot and is allowed
* to be a little wrong — mail arrives and is deleted while an import runs.
*/
public ?int $importTotal {
get {
$total = $this->getSetting(self::SETTING_IMPORT_TOTAL);
return null === $total ? null : max(0, (int) $total);
}
set (?int $total) {
$this->setSetting(self::SETTING_IMPORT_TOTAL, $total);
}
}
/**
* The Microsoft folders whose history has not been read to its end, in the
* order they are to be read.
*
* A Microsoft folder is followed by a delta link (see $graphDeltaLinks),
* and the only way to be given one is to enumerate the folder once. That
* enumeration IS the import, so a folder is in exactly one of two places:
* it has a delta link and the sync follows it, or it has an entry here and
* MailImporter is reading it a page at a time.
*
* Each entry is the folder's id, the moment it was planned — the sync asks
* for mail received since then, because a folder with no delta link has no
* other way to say that something new arrived — and the link to the next
* page of an enumeration that is under way, null before the first.
*
* A list and not a map keyed by folder id: the order is the point, and a
* JSON object does not promise to keep one.
*
* @var list<array{folder: string, since: int, next: string|null}>
*/
public array $graphImport {
get {
$entries = $this->getSetting(self::SETTING_GRAPH_IMPORT, []);
$clean = [];
foreach (is_array($entries) ? $entries : [] as $entry) {
if (false === is_array($entry) || false === is_string($entry['folder'] ?? null)) {
continue;
}
$next = $entry['next'] ?? null;
$clean[] = [
'folder' => $entry['folder'],
'since' => (int) ($entry['since'] ?? 0),
'next' => is_string($next) && '' !== $next ? $next : null,
];
}
return $clean;
}
set (array $entries) {
$this->setSetting(self::SETTING_GRAPH_IMPORT, array_values($entries));
}
}
/**
* Messages Microsoft reported as gone from a folder while another folder
* was still being imported.
*
* "Gone from a folder" is a deletion only if the message arrived in no
* other, and a folder whose history is still being read cannot say whether
* it did. A removal is reported once, so the ones that could not be judged
* are kept here and judged by the first sync that has every folder's
* answer. See GraphApiSyncer::sync().
*
* @var list<string>
*/
public array $graphRemovals {
get {
$ids = $this->getSetting(self::SETTING_GRAPH_REMOVALS, []);
return array_values(array_filter(is_array($ids) ? $ids : [], is_string(...)));
}
set (array $ids) {
$this->setSetting(self::SETTING_GRAPH_REMOVALS, array_values($ids));
}
}
/** Whether a Microsoft account has folders whose history is still to be read. */
public function needsGraphImport(): bool
{
return [] !== $this->graphImport;
}
/** Virtual for the same reason as $backfillTarget. */
public int $backfillAttempts {
get => max(0, (int) $this->getSetting(self::SETTING_BACKFILL_ATTEMPTS, 0));
+61
View File
@@ -164,6 +164,67 @@ class Mailbox
#[ORM\Column(options: ['default' => 0])]
public int $failedUidAttempts = 0;
/**
* How far down this folder's history has been brought in: every UID at or
* above this one has been dealt with, and everything below it is still to
* come.
*
* null nobody has looked yet. The next sync plans the import — see
* ImapImportPlan — and nothing is fetched until it has.
* 0 there is nothing left below. The import is over, or the folder
* was read whole before imports worked this way.
* n the import is under way and has reached n.
*
* WHY A SECOND MARKER BESIDE lastSeenUid
*
* lastSeenUid is a high-water mark and only moves up: it is what makes a
* poll ask for new mail alone. A first import run through it reads a
* folder from UID 1, oldest first, in one sitting — twenty thousand
* messages before today's, with the newest last, and the whole account's
* other mail waiting behind it (#42).
*
* So the two ends are separate. On first contact lastSeenUid is put at the
* TOP of the folder, which makes new mail arrive at once and for ever
* after, and this marker starts just above it and walks DOWN a page at a
* time on a queue of its own (MailImporter). The newest mail is the first
* page.
*/
#[ORM\Column(nullable: true)]
public ?int $importFloorUid = null;
/**
* How many messages this folder's import set out to bring in, and how many
* of those are still to come. For the progress line, and allowed to be a
* little wrong: mail is deleted and filed while an import runs.
*
* Null until the first page has asked the server what is there.
*/
#[ORM\Column(nullable: true)]
public ?int $importTotal = null;
#[ORM\Column(nullable: true)]
public ?int $importRemaining = null;
/**
* How many times in a row the page just above the floor has come back
* with a message that would not store.
*
* The import's own counter, apart from failedUid above, and counted by
* the page rather than by the message. The two ends of a folder are read
* by different workers, and one ledger between them would have a message
* failing at the top and one failing at the bottom take the count from
* each other on every pass — the fault holdForRetry() already describes,
* arrived at from a new direction. See ImapMailboxImporter.
*/
#[ORM\Column(options: ['default' => 0])]
public int $importPageAttempts = 0;
/** Whether there is still history to bring in, or nobody has looked yet. */
public function isImporting(): bool
{
return 0 !== $this->importFloorUid;
}
/**
+65
View File
@@ -6,11 +6,14 @@ use App\Domain\Helper\MailServerHost;
use App\Entity\Mail\Account;
use App\Service\Mail\MailPresetProvider;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -124,7 +127,69 @@ class AccountType extends AbstractType
'class' => 'form-select',
],
'label' => 'SMTP encryption',
])
// ── A second password, for sending ─────────────────────────────
// Two fields, and the first is the one that decides. A server that
// wants a different password to send is rare, so the form shows
// one password field and a button; the button ticks this box and
// reveals the second field (see settings--smtp-password and
// account/_fields.html.twig).
//
// The box is what is believed, not the field. A password manager
// will fill a password input it can find whether or not it is on
// screen, and a blank field on the edit form means "keep what is
// stored" — so neither "has a value" nor "is blank" says whether
// this account is meant to have a second password. The box does.
->add('separateSmtpPassword', CheckboxType::class, [
'mapped' => false,
'required' => false,
'label' => 'account.form.smtp.separate',
])
->add('smtpPassword', PasswordType::class, [
'attr' => [
'placeholder' => '••••••••',
'class' => 'form-input',
'autocomplete' => 'new-password',
] + PasswordManagerIgnore::SECRET,
'label' => 'account.form.smtp.password',
'always_empty' => true,
'required' => false,
]);
// What the account had before the form wrote on it: `always_empty`
// submits a blank for an untouched field, and by POST_SUBMIT that
// blank is already on the entity.
$stored = null;
$builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use (&$stored): void {
$account = $event->getData();
$stored = $account instanceof Account ? $account->smtpPassword : null;
$event->getForm()->get('separateSmtpPassword')->setData(null !== $stored && '' !== $stored);
});
$builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event) use (&$stored): void {
$account = $event->getData();
if (false === $account instanceof Account) {
return;
}
// Box clear: one password for both, whatever the hidden field
// was sent with.
if (true !== $event->getForm()->get('separateSmtpPassword')->getData()) {
$account->smtpPassword = null;
return;
}
// Box ticked, field blank: keep the stored one — the same rule the
// password above it follows on the edit form. On a new account
// there is none, and the account simply has no second password.
if (null === $account->smtpPassword || '' === $account->smtpPassword) {
$account->smtpPassword = $stored;
}
});
}
public function configureOptions(OptionsResolver $resolver): void
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Messaging\Handler;
use App\Infrastructure\Messaging\Message\ImportMailMessage;
use App\Service\Mail\MailImporter;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* One page of an account's history. Everything it does is MailImporter's.
*/
#[AsMessageHandler]
final readonly class ImportMailHandler
{
public function __construct(
private MailImporter $importer,
) {}
public function __invoke(ImportMailMessage $message): void
{
$this->importer->runPage($message->accountId);
}
}
@@ -10,6 +10,7 @@ use App\Entity\Mail\Account;
use App\Infrastructure\Messaging\Message\SyncAccountMessage;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MailboxRepository;
use App\Service\Mail\MailImporter;
use App\Service\Mail\SyncNotifier;
use App\Service\Mail\SyncOrigin;
use Doctrine\ORM\EntityManagerInterface;
@@ -33,6 +34,7 @@ final readonly class SyncAccountMessageHandler
#[AutowireIterator('app.account_syncer')]
private iterable $syncers,
private SyncOrigin $origin,
private MailImporter $importer,
) {}
public function __invoke(SyncAccountMessage $message): void
@@ -56,6 +58,23 @@ final readonly class SyncAccountMessageHandler
return;
}
// Asked for the same thing a sync has since done. The poll, a push and
// the Sync button each queue one of these, and behind a sync that took
// a while they used to run one after another, each over a mailbox the
// one before had just read. See SyncAccountMessage::$requestedAt.
$requestedAt = $message->requestedAt ?? null;
if (null !== $requestedAt && true === $account->isSyncedSince($requestedAt)) {
$this->logger->debug('Sync skipped: a later one has already gone through', [
'accountId' => $message->accountId,
]);
return;
}
$account->syncBeganAt = new \DateTimeImmutable();
$this->entityManager->flush();
$syncer = $this->resolveSyncer($account);
if (null === $syncer) {
@@ -129,6 +148,12 @@ final readonly class SyncAccountMessageHandler
$account->recordSyncSuccess();
$this->entityManager->flush();
// The sync brings in what is new and plans what is old; the old is
// fetched elsewhere, a page at a time. This starts that for an account
// that has just been planned, and restarts it for one whose import
// went quiet. See MailImporter.
$this->importer->ensureRunning($account);
foreach ($mailboxIds as $mailboxId) {
$mailbox = $this->mailboxRepository->find($mailboxId);
@@ -15,6 +15,7 @@ use App\Infrastructure\Messaging\Message\SyncGmailMessageBatchMessage;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MessageRepository;
use App\Service\Gmail\GmailAddressFilter;
use App\Service\Gmail\GmailApiSyncer;
use App\Service\Gmail\GmailMessageBuilder;
use App\Service\HarvestContactsService;
use App\Service\Label\ThreadLabelSynchronizer;
@@ -30,6 +31,7 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
#[AsMessageHandler]
final readonly class SyncGmailMessageBatchHandler
@@ -122,9 +124,20 @@ final readonly class SyncGmailMessageBatchHandler
'count' => count($fetch['retryable']),
]);
// Back onto the queue this kind of work belongs on. Left to the
// routing table a retry lands on the live queue, and while an
// account is importing that is its history jumping in front of
// the mail arriving now — a few ids at a time, on every batch
// Google throttled, which during an import is most of them.
$stamps = [new DelayStamp(self::RETRY_DELAY_MS)];
if (true === $account->needsBackfill()) {
$stamps[] = new TransportNamesStamp([GmailApiSyncer::IMPORT_QUEUE]);
}
$this->bus->dispatch(
new SyncGmailMessageBatchMessage($account->id, $fetch['retryable'], $this->origin->current()),
[new DelayStamp(self::RETRY_DELAY_MS)],
$stamps,
);
}
@@ -20,7 +20,9 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use App\Service\Graph\GraphApiSyncer;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
/**
* Imports one chunk of Graph messages.
@@ -232,9 +234,18 @@ final readonly class SyncGraphMessageBatchHandler
'count' => count($throttled),
]);
// A retry goes back where the batch came from. Left to the routing
// table it lands on the live queue, and while an account is importing
// nearly every throttled batch is the import's.
$stamps = [new DelayStamp(self::RETRY_DELAY_MS)];
if (true === $account->needsGraphImport()) {
$stamps[] = new TransportNamesStamp([GraphApiSyncer::IMPORT_QUEUE]);
}
$this->bus->dispatch(
new SyncGraphMessageBatchMessage((int) $account->id, $throttled, $this->origin->current()),
[new DelayStamp(self::RETRY_DELAY_MS)],
$stamps,
);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Messaging\Message;
/**
* Bring in the next page of an account's history, and ask again if there is
* more.
*
* Dispatched by MailImporter — once to start an account's import, and then by
* the handler of each page for the page after it. That chain is the import:
* there is no long job anywhere in it, only a succession of short ones, which
* is what lets several accounts import side by side (their pages interleave on
* the queue in arrival order) and lets the worker's heartbeat stay fresh.
*
* It carries the account and nothing else. Where the import has got to is on
* the account and its folders, not in the envelope: a page that is lost to a
* worker restart is then asked for again by the next message, whichever that
* is, instead of being skipped by one that thought it knew what came next.
*/
readonly class ImportMailMessage
{
public function __construct(
public int $accountId,
) {}
}
@@ -12,13 +12,33 @@ use App\Domain\Enum\Mail\SyncTrigger;
*
* $trigger says which of those it was, so the mail it brings in can be marked
* with how it got here. See SyncTrigger.
*
* $requestedAt is when the sync was asked for, which is what lets a pile of
* them collapse into one. The quarter-hour poll, a push and somebody pressing
* Sync each queue one of these, and nothing used to notice that they were all
* asking for the same thing: behind one slow sync they stacked up and each ran
* in turn (#42). A request is redundant exactly when a sync of the account
* BEGAN after the request was made and went through — that sync has already
* seen whatever the request was about. See Account::isSyncedSince().
*/
readonly class SyncAccountMessage
{
/**
* Unix time. Absent on a job queued before this existed — read it with
* `??`, like $trigger — and such a job is simply never redundant.
*/
public ?int $requestedAt;
public function __construct(
public int $accountId,
// Null on a job queued before this existed. Read it with `??`: such a
// job is unserialised without the property being set at all.
public ?SyncTrigger $trigger = null,
) {}
?int $requestedAt = null,
) {
// Stamped here rather than by every caller: there are five of them,
// and the one that forgot would be the one whose requests never
// collapse.
$this->requestedAt = $requestedAt ?? time();
}
}
+102 -13
View File
@@ -156,27 +156,116 @@ class MailboxRepository extends ServiceEntityRepository
}
/**
* How many of an account's folders have never finished a sync.
* How many of an account's folders still have history to bring in.
*
* What "the first import is still running" means for IMAP, which has no
* account-level cursor to ask: every folder that is synced at all gets a
* syncedAt at the end of MessageSyncer::syncMailbox(), so a folder without
* one is a folder still being read for the first time — or one discovered
* a moment ago, which is the same thing as far as its mail is concerned.
* account-level cursor to ask. See findImporting() for what counts.
*
* A count rather than a boolean so the caller can tell "none outstanding"
* from "no folders at all"; see InitialImportState.
*/
public function countAwaitingFirstSync(Account $account): int
{
return (int) $this->createQueryBuilder('mailbox')
->select('COUNT(mailbox.id)')
->andWhere('mailbox.account = :account')
->andWhere('mailbox.isSyncEnabled = :isSyncEnabled')
->andWhere('mailbox.syncedAt IS NULL')
->setParameter('account', $account)
->setParameter('isSyncEnabled', true)
return count($this->findImporting($account));
}
/**
* How much history the folders of each of these accounts set out to bring
* in, how much of it is still to come, and how many folders are not done.
*
* One grouped query for all of a person's accounts, because the topbar
* asks on every page and the answer is nearly always "none are". The
* conditions are findImporting()'s, except that finished folders are
* counted too: what they brought in is part of the total.
*
* A folder nobody has planned yet has no total and counts as importing,
* with nothing to add to the figures until its first page has asked the
* server what is there.
*
* @param list<Account> $accounts
*
* @return array<int, array{total: int, remaining: int, importing: int}> by account id
*/
public function importTotalsByAccount(array $accounts): array
{
if ([] === $accounts) {
return [];
}
$rows = $this->createQueryBuilder('mailbox')
->select('IDENTITY(mailbox.account) AS accountId')
->addSelect('COALESCE(SUM(mailbox.importTotal), 0) AS total')
->addSelect('COALESCE(SUM(mailbox.importRemaining), 0) AS remaining')
->addSelect('SUM(CASE WHEN mailbox.importFloorUid IS NULL OR mailbox.importFloorUid <> 0 THEN 1 ELSE 0 END) AS importing')
->andWhere('mailbox.account IN (:accounts)')
->andWhere('mailbox.isSyncEnabled = :enabled')
->andWhere('mailbox.isSelectable = :selectable')
->andWhere('mailbox.missingSince IS NULL')
->setParameter('accounts', $accounts)
->setParameter('enabled', true)
->setParameter('selectable', true)
->groupBy('mailbox.account')
->getQuery()
->getSingleScalarResult();
->getArrayResult();
$totals = [];
foreach ($rows as $row) {
$totals[(int) $row['accountId']] = [
'total' => (int) $row['total'],
'remaining' => (int) $row['remaining'],
'importing' => (int) $row['importing'],
];
}
return $totals;
}
/**
* An account's folders that still have history to bring in, the ones to
* read first leading.
*
* "Still importing" is the folder's own marker — see
* Mailbox::$importFloorUid — and not the absence of a syncedAt, which was
* the test while a first sync read a folder whole: a folder gets its
* syncedAt at the end of its first pass, and that pass now ends at once,
* with new mail in hand and the history still to come.
*
* Only folders that are synced at all, that the server still lists and
* that it will open: nothing fetches the others, so nothing would ever
* finish them.
*
* THE ORDER IS THE PRIORITY. The Inbox first, because it is what a person
* opens; then what they wrote; then their own folders; the archive after
* those, since on Gmail over IMAP "All Mail" is every message again; and
* Spam and the bin last, which nobody is waiting for. PHP sorts it rather
* than SQL: the rank is a rule about an enum, and it belongs beside the
* enum's other rules rather than in a CASE expression here.
*
* @return list<Mailbox>
*/
public function findImporting(Account $account): array
{
/** @var list<Mailbox> $mailboxes */
$mailboxes = $this->createQueryBuilder('mailbox')
->andWhere('mailbox.account = :account')
->andWhere('mailbox.isSyncEnabled = :enabled')
->andWhere('mailbox.isSelectable = :selectable')
->andWhere('mailbox.missingSince IS NULL')
->andWhere('mailbox.importFloorUid IS NULL OR mailbox.importFloorUid <> 0')
->setParameter('account', $account)
->setParameter('enabled', true)
->setParameter('selectable', true)
->orderBy('mailbox.fullPath', 'ASC')
->getQuery()
->getResult();
// Stable, so the path order survives within a rank.
usort(
$mailboxes,
static fn (Mailbox $a, Mailbox $b): int => MailboxSpecialUse::importRank($a->specialUse) <=> MailboxSpecialUse::importRank($b->specialUse),
);
return $mailboxes;
}
}
+17
View File
@@ -1445,6 +1445,23 @@ class MessageRepository extends ServiceEntityRepository
->getSingleColumnResult();
}
/**
* How many messages are stored for an account.
*
* For the progress line of a Gmail import, which has no folders to add
* up: what is stored against what the provider said the mailbox holds.
* Asked only while such an import is running.
*/
public function countForAccount(Account $account): int
{
return (int) $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->where('m.account = :account')
->setParameter('account', $account)
->getQuery()
->getSingleScalarResult();
}
/**
* What this mailbox's rows currently say about their flags, and nothing
* else about them.
@@ -318,6 +318,9 @@ final readonly class ConfigBackupUserRestorer
$account->smtpEncryption = $this->text($row, 'smtpEncryption');
$account->username = $this->text($row, 'username');
$account->password = $this->text($row, 'password');
// Absent from a backup made before the field existed, which reads
// as null: one password for both, as that account had.
$account->smtpPassword = $this->text($row, 'smtpPassword');
$account->authType = $this->text($row, 'authType');
$account->oauthProvider = $this->text($row, 'oauthProvider');
$account->oauthAccessToken = $this->text($row, 'oauthAccessToken');
+8
View File
@@ -218,6 +218,13 @@ final readonly class ConfigBackupUsers
Account::SETTING_BACKFILL_RAN_AT => 'a counter about a sync that happened on the other host',
Account::SETTING_BACKFILL_ATTEMPTS => 'the same, and resetting it on the target is the correct start',
Account::SETTING_BACKFILL_TARGET => 'how much mail the OTHER host still had to fetch; carried, a settled 0 tells Gmail on the target that its empty mailbox is complete and the existing mail is never imported',
Account::SETTING_BACKFILL_PAGE_TOKEN => 'a place in a listing the other host was walking; the target starts its own from the first page',
Account::SETTING_BACKFILL_PENDING => 'what that listing had found so far, which is nothing on a host that has not listed',
Account::SETTING_SYNC_BEGAN_AT => 'when the other host last started a sync; here it would make a sync nobody has run look done',
Account::SETTING_IMPORT_BEAT_AT => 'when the other host\'s import last moved; here it would hold a new import back as though one were running',
Account::SETTING_GRAPH_IMPORT => 'the folders the OTHER host had still to read, by ids and page links that mean nothing to a mailbox connected afresh; the target plans its own on its first sync',
Account::SETTING_GRAPH_REMOVALS => 'message ids the other host was waiting to judge, for rows this host does not have',
Account::SETTING_IMPORT_TOTAL => 'the size of the mailbox as the other host found it, for a progress line about an import that is not this one',
];
public function __construct(
@@ -442,6 +449,7 @@ final readonly class ConfigBackupUsers
'smtpEncryption' => $account->smtpEncryption,
'username' => $account->username,
'password' => $account->password,
'smtpPassword' => $account->smtpPassword,
'authType' => $account->authType,
'oauthProvider' => $account->oauthProvider,
'oauthAccessToken' => $account->oauthAccessToken,
+8 -2
View File
@@ -29,7 +29,10 @@ final readonly class GmailAccountSyncer implements AccountSyncerInterface
$this->labelSyncer->sync($account);
if (null === $account->gmailHistoryId) {
$this->gmailApiSyncer->initialSync($account);
// Not initialSync(): that lists the whole mailbox before it
// returns. The listing belongs to MailImporter, a page at a time
// on the import queue — see GmailApiSyncer::beginImport().
$this->gmailApiSyncer->beginImport($account);
return [];
}
@@ -39,8 +42,11 @@ final readonly class GmailAccountSyncer implements AccountSyncerInterface
// only says where incremental sync resumes, and treating it as "the
// mailbox is fully synced" is what used to strand accounts on
// whatever the first run happened to fetch.
//
// The backlog itself is no longer walked here. SyncAccountMessageHandler
// asks MailImporter to see that it is under way, which is the same
// "every sync resumes it" with the listing off this queue.
$this->gmailApiSyncer->syncIncremental($account);
$this->gmailApiSyncer->backfill($account);
return [];
}
+132 -28
View File
@@ -15,6 +15,7 @@ use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
/**
* Plans Gmail sync work and fans it out to SyncGmailMessageBatchMessage jobs.
@@ -26,12 +27,18 @@ use Symfony\Component\Messenger\MessageBusInterface;
*/
final class GmailApiSyncer
{
/** The transport a mailbox's history is fetched on. See messenger.yaml. */
public const string IMPORT_QUEUE = 'ingest_backlog';
/** Message-list page size (API max is 500). */
private const int PAGE_SIZE = 500;
/** Gmail message IDs per fan-out batch. */
private const int BATCH_SIZE = 100;
/** How many of the Inbox's newest messages an import asks for by name, first. */
private const int INBOX_SLICE = 200;
/** Seconds before an unfinished backfill lists again. */
private const int BACKFILL_COOLDOWN = 3600;
@@ -83,39 +90,129 @@ final class GmailApiSyncer
*/
public function backfill(Account $account): void
{
if (false === $account->needsBackfill()) {
return;
// The whole listing in one call, onto the queue live mail uses. That
// is right for the caller that still has one — a history cursor that
// expired, where the mailbox is already here and the listing is there
// to find what the gap lost — and it is exactly what a first import
// must not do. MailImporter walks backfillPage() instead.
while (GmailBackfillStep::More === $this->backfillPage($account, false)) {
continue;
}
}
/**
* Note where incremental sync starts and how big the mailbox is, and stop.
*
* The first thing done for a new account, in place of initialSync(): that
* one goes on to list the whole mailbox before it returns, which for a
* large account was twelve minutes of one job on the queue everything else
* was waiting on (#42). The listing is MailImporter's now, a page at a
* time on the import queue, and this only has to leave the account in a
* state where new mail is seen and the import knows what it is aiming at.
*/
public function beginImport(Account $account): void
{
$profile = $this->apiClient->getProfile($account);
$currentHistoryId = (string) ($profile['historyId'] ?? '');
if ('' !== $currentHistoryId) {
$account->gmailHistoryId = $currentHistoryId;
}
// Batches are dispatched asynchronously, so a listing run started
// minutes ago is probably still draining. Re-listing now would find
// the same ids missing and dispatch them a second time; an hour is
// long enough for the queue to have made visible progress.
$ranAt = $account->backfillRanAt;
if (null !== $ranAt && $ranAt > new \DateTimeImmutable(sprintf('-%d seconds', self::BACKFILL_COOLDOWN))) {
return;
if (true === isset($profile['messagesTotal'])) {
$account->importTotal = (int) $profile['messagesTotal'];
}
$this->logger->info('GmailApiSyncer: planning backfill', [
'accountId' => $account->id,
'account' => $account->email,
'completed' => $account->backfillTarget ?? 'never',
$this->em->flush();
}
/**
* The newest of the Inbox, ahead of everything else.
*
* The listing proper is of the whole mailbox, newest first with every
* label mixed — and in a mailbox where the newest five hundred messages
* are promotions and the archive of a mailing list, that is what arrived
* first. One extra request asks for the Inbox by name, and its messages
* are on the queue before the first page of the rest.
*
* They turn up again in the listing that follows and are queued a second
* time. That is left alone: by then they are stored, and a batch of
* stored ids is one lookup and no fetch.
*/
public function importInboxFirst(Account $account): void
{
$listed = $this->apiClient->listMessagesPage($account, [
'maxResults' => self::INBOX_SLICE,
'labelIds' => 'INBOX',
]);
$account->backfillRanAt = new \DateTimeImmutable();
$this->em->flush();
$this->dispatchBatches($account, $this->newGmailIds($account, $listed['messages']), true);
}
/**
* One page of the listing: fetch what it names that is not here yet, and
* say whether there is another.
*
* The state between pages is on the account — the token for the next page
* and how much the pages so far have found — so each page can be a job of
* its own and a worker restart loses one page, not the listing.
*
* @param bool $toImportQueue false only for backfill() above
*/
public function backfillPage(Account $account, bool $toImportQueue = true): GmailBackfillStep
{
if (false === $account->needsBackfill()) {
return GmailBackfillStep::Finished;
}
$pageToken = $account->backfillPageToken;
if (null === $pageToken) {
// A new listing. Batches are dispatched asynchronously, so one
// that ended minutes ago is probably still draining. Listing again
// now would find the same ids missing and dispatch them a second
// time; an hour is long enough for the queue to have made visible
// progress.
$ranAt = $account->backfillRanAt;
if (null !== $ranAt && $ranAt > new \DateTimeImmutable(sprintf('-%d seconds', self::BACKFILL_COOLDOWN))) {
return GmailBackfillStep::Waiting;
}
$this->logger->info('GmailApiSyncer: planning backfill', [
'accountId' => $account->id,
'account' => $account->email,
'completed' => $account->backfillTarget ?? 'never',
]);
$account->backfillRanAt = new \DateTimeImmutable();
$account->backfillPending = 0;
$this->em->flush();
}
// No labelIds filter — fetch all mail (inbox, sent, spam, trash, …).
$messageRefs = $this->apiClient->listMessages($account, [
'maxResults' => self::PAGE_SIZE,
]);
$listed = $this->apiClient->listMessagesPage($account, ['maxResults' => self::PAGE_SIZE], $pageToken);
$pending = $this->newGmailIds($account, $messageRefs);
$pending = $this->newGmailIds($account, $listed['messages']);
$this->dispatchBatches($account, $pending);
$this->dispatchBatches($account, $pending, $toImportQueue);
$this->settleBackfill($account, count($pending));
$found = $account->backfillPending + count($pending);
if (null !== $listed['nextPageToken']) {
$account->backfillPageToken = $listed['nextPageToken'];
$account->backfillPending = $found;
$this->em->flush();
return GmailBackfillStep::More;
}
$account->backfillPageToken = null;
$account->backfillPending = 0;
$this->settleBackfill($account, $found);
return true === $account->needsBackfill() ? GmailBackfillStep::Waiting : GmailBackfillStep::Finished;
}
/**
@@ -489,7 +586,7 @@ final class GmailApiSyncer
/**
* @param list<string> $gmailIds
*/
private function dispatchBatches(Account $account, array $gmailIds): void
private function dispatchBatches(Account $account, array $gmailIds, bool $toImportQueue = false): void
{
if (count($gmailIds) === 0) {
return;
@@ -498,11 +595,18 @@ final class GmailApiSyncer
$batches = array_chunk($gmailIds, self::BATCH_SIZE);
foreach ($batches as $batch) {
$this->bus->dispatch(new SyncGmailMessageBatchMessage(
(int) $account->id,
$batch,
$this->origin->current(),
));
// An import's batches go to the import queue and its worker, so a
// mailbox's history is never in front of the mail arriving now.
// By stamp, because the message is the same one either way and
// only this method knows which it is — see messenger.yaml.
$this->bus->dispatch(
new SyncGmailMessageBatchMessage(
(int) $account->id,
$batch,
$this->origin->current(),
),
true === $toImportQueue ? [new TransportNamesStamp([self::IMPORT_QUEUE])] : [],
);
}
$this->logger->info('GmailApiSyncer: batches dispatched', [
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Service\Gmail;
/**
* What one page of a Gmail backfill listing left to do.
*/
enum GmailBackfillStep
{
/** There is another page to this listing. Ask for it. */
case More;
/**
* Nothing to list just now: the last listing found mail that is still
* being fetched, and the next one is not due until that has had its hour.
*/
case Waiting;
/** The whole mailbox is in. */
case Finished;
}
+5 -2
View File
@@ -38,8 +38,11 @@ use Symfony\Component\Clock\ClockInterface;
* syncs, and whatever else is signed in to the same Google account.
*
* IN MEMORY, in this process. That is enough because the bulk fetching is one
* worker's job, and it survives from one message to the next because a worker
* only resets services that ask to be. A restart forgets what was spent and
* worker's job — worker-backlog, which runs an import's listing and its
* batches; worker-ingest also talks to Gmail, but for the handful of calls a
* "see what is new" sync makes, and those are what the headroom above is left
* for. It survives from one message to the next because a worker only resets
* services that ask to be. A restart forgets what was spent and
* starts with a full reserve, which can overshoot for at most the reserve.
* Shared storage would close that, at the price of a database round trip per
* batch to guard against something GmailThrottledException already handles.
+297 -32
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Graph;
use App\Domain\DTO\Mail\RemoteFlagState;
use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Jmap\State\JmapObjectType;
@@ -19,13 +20,24 @@ use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
/**
* Plans Graph sync work and fans it out to SyncGraphMessageBatchMessage jobs.
*
* Unlike Gmail there is no initial/incremental split: a delta query with no
* stored deltaLink enumerates the whole folder and hands back a link, and the
* same call with a link returns only changes. One code path covers both.
* Graph itself has no initial/incremental split: a delta query with no stored
* deltaLink enumerates the whole folder and hands back a link, and the same
* call with a link returns only changes. plMail used to take it at its word
* and run both through one code path, which made the first sync of an account
* a listing of every folder to its end followed by every message it held, on
* the queue new mail arrives on (#42).
*
* So the two are separate here although they are one call there. sync()
* follows the folders that have a link. A folder without one is PLANNED — see
* Account::$graphImport — and its enumeration is importPage()'s, a page at a
* time on the import queue, newest mail first and the Inbox before anything
* else. Until that ends the sync asks such a folder only what it received
* since it was planned, which is the one thing that cannot wait.
*
* Delta state is per FOLDER, not per account (Gmail's single historyId has no
* equivalent here), so it lives in a folderId => deltaLink map on the Account.
@@ -49,6 +61,30 @@ final class GraphApiSyncer
/** Graph message ids per fan-out batch — the $batch sub-request ceiling. */
private const int BATCH_SIZE = GraphApiClient::BATCH_LIMIT;
/** Where an import's batches go. See messenger.yaml. */
public const string IMPORT_QUEUE = 'ingest_backlog';
/**
* Messages asked for per page of a folder's first enumeration. The same
* fifty an IMAP import page is, for the same reason: small enough that a
* page is seconds and a restart loses little, large enough that the
* listing is not most of the work.
*/
public const int IMPORT_PAGE_SIZE = 50;
/**
* How many newly received messages a folder still being imported is asked
* for per sync. More than this between two syncs is the import's to find.
*/
private const int RECEIVED_LIMIT = 50;
/**
* How many unjudged removals are kept. See Account::$graphRemovals. Far
* more than an import sees deleted under it; a bound all the same, because
* the list lives in the account's settings.
*/
private const int REMOVALS_KEPT = 5000;
public function __construct(
private readonly GraphApiClient $apiClient,
private readonly GraphFolderResolver $folderResolver,
@@ -71,6 +107,12 @@ final class GraphApiSyncer
$deltaLinks = $account->graphDeltaLinks;
$pending = [];
// Which folders are the import's, and since when. Planned before
// anything is asked, so a folder met for the first time is never
// enumerated here.
$planned = array_column($account->graphImport, 'since', 'folder');
$importing = array_column($this->plan($account, $folderIds, $deltaLinks), 'since', 'folder');
// The two halves of "is this message anywhere". A move shows up as
// `@removed` on the folder it left and as an ordinary item on the one
// it arrived in, so a removal on its own means nothing until every
@@ -84,13 +126,23 @@ final class GraphApiSyncer
// whether it received anything, so this run cannot conclude that a
// message is nowhere — exactly the reasoning
// MailboxRepository::earliestSweepAcross() applies on the IMAP side.
$everyFolderAnswered = true;
// A folder that is still being imported has not told us either.
$everyFolderAnswered = [] === $importing;
foreach ($folderIds as $folderId) {
$storedLink = $deltaLinks[$folderId] ?? null;
if (true === array_key_exists($folderId, $importing)) {
// Planned a moment ago: nothing can have arrived since.
if (true === array_key_exists($folderId, $planned)) {
foreach ($this->receivedSince($account, $folderId, (int) $importing[$folderId]) as $graphId) {
$pending[$graphId] = true;
}
}
continue;
}
try {
$result = $this->apiClient->deltaMessages($account, $folderId, $storedLink);
$result = $this->apiClient->deltaMessages($account, $folderId, $deltaLinks[$folderId]);
} catch (\Throwable $e) {
$this->logger->error('GraphApiSyncer: delta failed', [
'accountId' => $account->id,
@@ -105,12 +157,27 @@ final class GraphApiSyncer
}
if (true === $result['resyncRequired']) {
$this->logger->warning('GraphApiSyncer: delta token expired, re-enumerating folder', [
$this->logger->warning('GraphApiSyncer: delta token expired, folder handed back to the import', [
'accountId' => $account->id,
'folderId' => $folderId,
]);
// The link is dead and the folder has to be enumerated again,
// which is the import's work and used to be done right here:
// the whole folder, in the sync, in front of new mail. It is
// planned instead — from the last sync that went through,
// because that is how far back "what arrived since" has to
// reach for a folder that was being followed until now.
unset($deltaLinks[$folderId]);
$plan = $account->graphImport;
$plan[] = [
'folder' => $folderId,
'since' => ($account->lastSyncedAt ?? new DateTimeImmutable())->getTimestamp(),
'next' => null,
];
$account->graphImport = $plan;
$account->graphDeltaLinks = $deltaLinks;
// Same count as the Gmail path keeps, and it matters more here:
@@ -122,20 +189,9 @@ final class GraphApiSyncer
$this->em->flush();
try {
$result = $this->apiClient->deltaMessages($account, $folderId, null);
} catch (\Throwable $e) {
$this->logger->error('GraphApiSyncer: re-enumeration failed', [
'accountId' => $account->id,
'folderId' => $folderId,
'error' => $e->getMessage(),
'exception' => $e,
]);
$everyFolderAnswered = false;
$everyFolderAnswered = false;
continue;
}
continue;
}
foreach ($result['items'] as $item) {
@@ -165,23 +221,225 @@ final class GraphApiSyncer
$account->graphDeltaLinks = $deltaLinks;
if (true === $everyFolderAnswered) {
$this->eraseVanished($account, array_diff_key($removed, $present));
}
$this->em->flush();
// strval, because array_keys() on an id-keyed array does not return
// strings: PHP converts a decimal-integer-like key to an int on the way
// in. Graph ids are long base64-ish strings and almost never all digits,
// which is exactly what makes this the kind of bug that waits — the
// Gmail equivalent killed whole batches with
// `urlencode(): Argument #1 must be of type string, int given`.
$unpaired = array_map(strval(...), array_keys(array_diff_key($removed, $present)));
$this->settleRemovals($account, $unpaired, $everyFolderAnswered);
$this->em->flush();
$this->dispatchBatches($account, array_map(strval(...), array_keys($pending)));
}
/**
* One page of the history of the folder whose turn it is.
*
* The first folder in Account::$graphImport is read until it is done, so
* the Inbox is whole before the archive is begun. A page is listed, what
* it names that is not stored yet is sent to be fetched on the import
* queue, and the link to the next page is left on the account for the
* next call. The page that ends the enumeration carries the folder's
* delta link instead, and with that the folder is the sync's.
*
* A folder that cannot be read goes to the back and the answer is
* Waiting. Thrown, the failure would take the chain of pages with it and
* hold every folder behind this one for as long as it stays shut.
*/
public function importPage(Account $account): GraphImportStep
{
$plan = $account->graphImport;
if ([] === $plan) {
return GraphImportStep::Finished;
}
$entry = array_shift($plan);
$folderId = $entry['folder'];
try {
$page = $this->apiClient->deltaMessagesPage($account, $folderId, $entry['next'], self::IMPORT_PAGE_SIZE);
} catch (\Throwable $e) {
$this->logger->warning('GraphApiSyncer: import page failed, folder passed over for now', [
'accountId' => $account->id,
'folderId' => $folderId,
'error' => $e->getMessage(),
'exception' => $e,
]);
$account->graphImport = [...$plan, $entry];
$this->em->flush();
return GraphImportStep::Waiting;
}
if (true === $page['resyncRequired']) {
// The server has forgotten the enumeration it was part-way
// through. From the top: what is already stored is recognised and
// not fetched again, so this costs listings and nothing else.
$account->graphImport = [['folder' => $folderId, 'since' => $entry['since'], 'next' => null], ...$plan];
$this->em->flush();
return GraphImportStep::More;
}
$this->dispatchBatches($account, $this->partition($account, $folderId, $page['items']), true);
if (null !== $page['deltaLink']) {
$deltaLinks = $account->graphDeltaLinks;
$deltaLinks[$folderId] = $page['deltaLink'];
$account->graphDeltaLinks = $deltaLinks;
$account->graphImport = $plan;
$this->logger->info('GraphApiSyncer: folder imported', [
'accountId' => $account->id,
'folderId' => $folderId,
'remaining' => count($plan),
]);
} elseif (null !== $page['nextLink']) {
$account->graphImport = [['folder' => $folderId, 'since' => $entry['since'], 'next' => $page['nextLink']], ...$plan];
} else {
// Neither link. Graph ends every page with one or the other, so
// this is a response that was not what was asked for; there is
// nowhere to continue from, and starting over at once would ask
// the same thing again.
$this->logger->warning('GraphApiSyncer: import page carried no link, folder starts over', [
'accountId' => $account->id,
'folderId' => $folderId,
]);
$account->graphImport = [...$plan, ['folder' => $folderId, 'since' => $entry['since'], 'next' => null]];
$this->em->flush();
return GraphImportStep::Waiting;
}
$this->em->flush();
return [] === $account->graphImport ? GraphImportStep::Finished : GraphImportStep::More;
}
// ── Private ───────────────────────────────────────────────────────────────
/**
* Work out which folders are the import's, in the order it reads them, and
* leave that on the account.
*
* A folder is the import's exactly when it has no delta link. One that is
* already planned keeps its entry — its place in an enumeration and the
* moment it was planned — and one met for the first time is planned as of
* now. A planned folder that has gone from the mailbox drops out.
*
* Sorted every time rather than once, because roles are found by the
* folder sync and a folder can be planned before its role is known.
*
* @param list<string> $folderIds
* @param array<string, string> $deltaLinks
* @return list<array{folder: string, since: int, next: string|null}>
*/
private function plan(Account $account, array $folderIds, array $deltaLinks): array
{
$known = array_column($account->graphImport, null, 'folder');
$now = time();
$plan = [];
foreach ($folderIds as $folderId) {
if (true === array_key_exists($folderId, $deltaLinks)) {
continue;
}
$plan[] = $known[$folderId] ?? ['folder' => $folderId, 'since' => $now, 'next' => null];
}
// Stable, so folders of one rank keep the order Graph listed them in
// and the one being read stays the one being read.
usort(
$plan,
fn (array $a, array $b): int => $this->rank($account, $a['folder']) <=> $this->rank($account, $b['folder']),
);
if ($plan !== $account->graphImport) {
$account->graphImport = $plan;
$this->em->flush();
}
return $plan;
}
private function rank(Account $account, string $folderId): int
{
return LabelRole::importRank($this->folderResolver->resolveFolder($folderId, $account)?->role);
}
/**
* What a folder still being imported has received since it was planned
* that is not stored yet.
*
* Failing is not worth more than a line in the log: the folder is being
* read by the import anyway, and the next sync asks again.
*
* @return list<string>
*/
private function receivedSince(Account $account, string $folderId, int $since): array
{
try {
$items = $this->apiClient->listReceivedSince(
$account,
$folderId,
new DateTimeImmutable('@' . $since),
self::RECEIVED_LIMIT,
);
} catch (\Throwable $e) {
$this->logger->warning('GraphApiSyncer: could not ask an importing folder for new mail', [
'accountId' => $account->id,
'folderId' => $folderId,
'error' => $e->getMessage(),
'exception' => $e,
]);
return [];
}
return $this->partition($account, $folderId, $items);
}
/**
* Act on the removals that arrived in no folder, or keep them until that
* can be known.
*
* A removal is reported once. Dropping the ones a run could not judge —
* which is what happened whenever one folder's delta failed — left the
* rows of deleted mail behind for good: no folder label, so in no list,
* and still in every count. They are kept on the account instead and
* judged with the next run whose every folder answered; eraseVanished()
* leaves alone any that turned up in a folder in the meantime.
*
* @param list<string> $unpaired
*/
private function settleRemovals(Account $account, array $unpaired, bool $everyFolderAnswered): void
{
$kept = array_values(array_unique([...$account->graphRemovals, ...$unpaired]));
if ([] === $kept) {
return;
}
if (false === $everyFolderAnswered) {
$account->graphRemovals = array_slice($kept, -self::REMOVALS_KEPT);
return;
}
$this->eraseVanished($account, array_fill_keys($kept, true));
$account->graphRemovals = [];
}
/**
* Remove the rows for messages that left a folder and arrived in none.
*
@@ -443,7 +701,7 @@ final class GraphApiSyncer
/**
* @param list<string> $graphIds
*/
private function dispatchBatches(Account $account, array $graphIds): void
private function dispatchBatches(Account $account, array $graphIds, bool $toImportQueue = false): void
{
if (count($graphIds) === 0) {
$this->logger->info('GraphApiSyncer: nothing new to fetch', [
@@ -456,11 +714,18 @@ final class GraphApiSyncer
$chunks = array_chunk($graphIds, self::BATCH_SIZE);
foreach ($chunks as $chunk) {
$this->bus->dispatch(new SyncGraphMessageBatchMessage(
(int) $account->id,
array_values($chunk),
$this->origin->current(),
));
// An import's batches go to the import queue and its worker, so a
// folder's history is never in front of the mail arriving now. By
// stamp, for the reason GmailApiSyncer gives: the message is the
// same one either way and only this method knows which it is.
$this->bus->dispatch(
new SyncGraphMessageBatchMessage(
(int) $account->id,
array_values($chunk),
$this->origin->current(),
),
true === $toImportQueue ? [new TransportNamesStamp([self::IMPORT_QUEUE])] : [],
);
}
$this->logger->info('GraphApiSyncer: dispatched message batches', [
+7
View File
@@ -126,6 +126,7 @@ final readonly class GraphFolderSyncer
$syncable = [];
$synced = 0;
$messages = 0;
foreach ($byId as $id => $folder) {
$wellKnown = $nameByFolderId[$id] ?? '';
@@ -161,6 +162,7 @@ final readonly class GraphFolderSyncer
$syncable[] = $id;
$synced++;
$messages += max(0, (int) ($folder['totalItemCount'] ?? 0));
}
// Drop stale links: a binding pointing at a folder that no longer
@@ -178,6 +180,11 @@ final readonly class GraphFolderSyncer
}
}
// What the progress line of an import measures itself against. The
// folder list already says it, so it is kept current here rather than
// asked for separately.
$account->importTotal = $messages;
$this->em->flush();
$this->logger->info('GraphFolderSyncer: folders synced', [
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Service\Graph;
/**
* What one page of a Microsoft folder's first enumeration left to do.
*/
enum GraphImportStep
{
/** There is more to read, in this folder or the next. Ask for it. */
case More;
/**
* The folder whose turn it was could not be read just now. It has gone to
* the back of the order; asking again at once would only ask the next one
* the same question a second sooner than it needs to be.
*/
case Waiting;
/** Every folder has been read to its end and is followed by its delta link. */
case Finished;
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Service\Imap;
/**
* What became of one page of a folder's import, in terms of what to do next.
*
* Four answers because the caller has four things it can do, and a boolean
* "is there more" would fold together the two that must not be: a page that
* went through, whose successor should follow at once, and a page that held
* something back, which is the same messages again and gains nothing from
* being asked for a second later.
*/
enum ImapImportOutcome
{
/** The page is in and there is history below it. Ask for the next. */
case More;
/** Something would not store and the floor stayed. Ask again, later. */
case Retry;
/** Nothing could be done just now — no folder, no plan. Ask again, later. */
case Waiting;
/** This folder has no history left. */
case Finished;
/** Whether the same folder should be asked again, as opposed to moved on from. */
public function isUnfinished(): bool
{
return match ($this) {
self::More, self::Retry, self::Waiting => true,
self::Finished => false,
};
}
/** Whether asking again straight away would only repeat what just happened. */
public function shouldWait(): bool
{
return match ($this) {
self::Retry, self::Waiting => true,
self::More, self::Finished => false,
};
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Service\Imap;
use App\Entity\Mail\Mailbox;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use Webklex\PHPIMAP\Client;
/**
* The moment a folder is first met: where new mail begins, and where its
* history is to be read from.
*
* A folder used to have one marker, lastSeenUid, and a first sync was that
* marker walking up from nothing — the whole folder, oldest first, in one job.
* Here the two jobs that was doing are pulled apart (see
* Mailbox::$importFloorUid):
*
* - lastSeenUid goes straight to the TOP of the folder. From this instant a
* poll or an IDLE notice brings in new mail and only new mail, whatever
* state the history is in.
* - importFloorUid starts one above it, so the history below is everything
* the folder holds, and ImapMailboxImporter brings it in from the top
* down, a page at a time, on its own queue.
*
* Deliberately a class of its own and not a method of the importer: it is
* called from MessageSyncer, which the importer in turn depends on, and a
* constructor cannot be owed to itself.
*
* "The top" is UIDNEXT minus one, read off an EXAMINE. A server that does not
* say what its next UID will be has not given enough to plan with, and the
* folder is left unplanned: the sync then reads it the old way, bottom up,
* which is slow and correct.
*/
final readonly class ImapImportPlan
{
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {}
/** @return bool whether the folder is planned now */
public function begin(Mailbox $mailbox, Client $client): bool
{
$top = $this->highestUid($mailbox, $client);
if (null === $top) {
return false;
}
// Never down. A folder half-read by the old oldest-first pass has a
// mark in its middle; everything between there and the top is simply
// history now, and the importer skips what is already stored.
$mailbox->lastSeenUid = max($mailbox->lastSeenUid ?? 0, $top);
// An empty folder has no history, and saying so here is what keeps it
// off the importer's list.
$mailbox->importFloorUid = 0 < $top ? $top + 1 : 0;
$mailbox->importTotal = null;
$mailbox->importRemaining = null;
$mailbox->importPageAttempts = 0;
$this->em->flush();
$this->logger->info('Planned a folder\'s import: new mail from the top, history below it', [
'mailboxId' => $mailbox->id,
'mailbox' => $mailbox->fullPath,
'top' => $top,
]);
return true;
}
private function highestUid(Mailbox $mailbox, Client $client): ?int
{
$path = (string) ($mailbox->fullPath ?? $mailbox->name);
try {
$examined = $client->getConnection()->examineFolder($path)->validatedData();
} catch (Throwable $e) {
$this->logger->info('Import not planned: the folder could not be examined', [
'mailbox' => $path,
'error' => $e->getMessage(),
]);
return null;
}
if (false === is_array($examined) || false === isset($examined['uidnext'])) {
$this->logger->info('Import not planned: no UIDNEXT in the EXAMINE response', [
'mailbox' => $path,
]);
return null;
}
return max(0, (int) $examined['uidnext'] - 1);
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Service\Imap;
use App\Entity\Mail\Mailbox;
use App\Domain\Helper\ImapFolderLocator;
use App\Repository\Mail\MailboxRepository;
use App\Repository\Mail\MessageRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Webklex\PHPIMAP\Client;
/**
* One page of a folder's history: the newest messages it holds that are not
* here yet.
*
* A first import used to be the sync itself, reading a folder from UID 1 to
* its end in one job — oldest mail first, today's last, and every other folder
* and account waiting behind it (#42). This is what replaced that. The sync
* puts its mark at the top of the folder and stops (ImapImportPlan); what is
* below is brought in here, from the top down, PAGE_SIZE messages at a call,
* and MailImporter decides whose page is next.
*
* ## What a page is
*
* Not a range. UIDs have holes, and a range of fifty UIDs is anything from
* fifty messages to none. The server is asked which UIDs exist below the
* floor — one SEARCH, a few bytes a message — the ones already stored are
* taken out, and the highest PAGE_SIZE of the rest are fetched by name.
* "Already stored" matters more than it looks: Gmail over IMAP shows every
* message in All Mail as well as its folder, a folder half-read by the old
* oldest-first pass is planned again from the top, and in both cases the
* messages that are here are never downloaded a second time to find that out.
*
* ## When a message will not store
*
* The floor does not move past a page that held something back, so the next
* page is the same messages again — minus the ones that did store, which are
* no longer missing. After MAX_PAGE_ATTEMPTS of that the floor moves anyway
* and the page's leftovers are logged and given up: the alternative is a
* folder whose history stops for good at one message the parser dislikes.
*
* Counted by the page and not by the message — see
* Mailbox::$importPageAttempts for why the ledger new mail uses cannot be
* shared.
*/
final readonly class ImapMailboxImporter
{
/** Messages to a page. MessageSyncer writes a page in one transaction. */
public const int PAGE_SIZE = 50;
/** Tries at a page that keeps holding something back before it is passed. */
public const int MAX_PAGE_ATTEMPTS = 5;
public function __construct(
private MessageSyncer $messageSyncer,
private ImapImportPlan $plan,
private ImapPagedFetch $pagedFetch,
private MailboxRepository $mailboxes,
private MessageRepository $messages,
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {}
/**
* Bring in the next page of this folder.
*
* @return ImapImportOutcome what became of it; the caller schedules what
* follows
*/
public function importPage(int $mailboxId, Client $client): ImapImportOutcome
{
$mailbox = $this->mailboxes->find($mailboxId);
if (null === $mailbox || false === $mailbox->isImporting()) {
return ImapImportOutcome::Finished;
}
// Met here before any sync got to it — a folder created a moment ago.
if (null === $mailbox->importFloorUid && false === $this->plan->begin($mailbox, $client)) {
return ImapImportOutcome::Waiting;
}
$floor = (int) $mailbox->importFloorUid;
if (0 === $floor) {
return ImapImportOutcome::Finished;
}
$folder = ImapFolderLocator::of($client, $mailbox);
if (null === $folder) {
$this->logger->warning('Import: the folder is not on the server; nothing imported', [
'mailboxId' => $mailboxId,
'mailbox' => $mailbox->fullPath,
]);
return ImapImportOutcome::Waiting;
}
$missing = $this->missingBelow($mailbox, $folder, $floor);
// Written at once: storing a page clears the entity manager, and a
// total set on this object and flushed "later" would be set on nothing.
if (null === $mailbox->importTotal) {
$mailbox->importTotal = count($missing);
$mailbox->importRemaining = count($missing);
$this->em->flush();
}
if ([] === $missing) {
$this->finish($mailbox);
return ImapImportOutcome::Finished;
}
$page = array_slice($missing, 0, self::PAGE_SIZE);
$path = (string) $mailbox->fullPath;
$query = $folder->messages()->where(MessageSyncer::uidSetCriteria($page));
$lowestHeld = null;
foreach ($this->pagedFetch->pages($query, self::PAGE_SIZE) as [$batch, $unreadable]) {
// Found again for each: storing a page clears the entity manager.
$current = $this->mailboxes->find($mailboxId);
if (null === $current) {
return ImapImportOutcome::Finished;
}
$held = $this->messageSyncer->storeImportPage($current, $batch, $unreadable);
if (null !== $held) {
$lowestHeld = null === $lowestHeld ? $held : min($lowestHeld, $held);
}
}
$mailbox = $this->mailboxes->find($mailboxId);
if (null === $mailbox) {
return ImapImportOutcome::Finished;
}
$outcome = $this->settle($mailbox, $page, count($missing), $lowestHeld, $path);
$mailbox->unreadMessages = $this->messages->countUnseenForMailbox($mailbox);
$mailbox->totalMessages = $this->messages->countTotalForMailbox($mailbox);
$this->em->flush();
return $outcome;
}
/**
* The UIDs the server holds below the floor that are not stored here,
* highest first.
*
* @return list<int>
*/
private function missingBelow(Mailbox $mailbox, \Webklex\PHPIMAP\Folder $folder, int $floor): array
{
$onServer = $folder->messages()
->where(MessageSyncer::uidRangeCriteria(sprintf('1:%d', $floor - 1)))
->search()
->all();
// `1:n` on a folder whose highest UID is below n answers with that
// highest one, the way `*` does. Anything at or above the floor has
// been dealt with and is not history.
$onServer = array_filter(array_map('intval', $onServer), static fn (int $uid): bool => $uid < $floor);
$stored = array_flip(array_map('intval', $this->messages->findSyncedUids($mailbox, 0)));
$missing = array_values(array_filter($onServer, static fn (int $uid): bool => false === isset($stored[$uid])));
rsort($missing);
return $missing;
}
/**
* Move the floor, or count another try at a page that held something back.
*
* @param list<int> $page
*/
private function settle(Mailbox $mailbox, array $page, int $missing, ?int $lowestHeld, string $path): ImapImportOutcome
{
if (null !== $lowestHeld && ++$mailbox->importPageAttempts < self::MAX_PAGE_ATTEMPTS) {
// The floor stays. What did store is no longer missing, so the
// next page is the leftovers and whatever is next below them.
return ImapImportOutcome::Retry;
}
if (null !== $lowestHeld) {
$this->logger->error('Import: messages that would not store are being passed over for good', [
'mailboxId' => $mailbox->id,
'mailbox' => $path,
'from' => min($page),
'to' => max($page),
'attempts' => $mailbox->importPageAttempts,
]);
}
$mailbox->importPageAttempts = 0;
$mailbox->importFloorUid = min($page);
$mailbox->importRemaining = max(0, $missing - count($page));
if (0 === $mailbox->importRemaining) {
$this->finish($mailbox);
return ImapImportOutcome::Finished;
}
return ImapImportOutcome::More;
}
private function finish(Mailbox $mailbox): void
{
$mailbox->importFloorUid = 0;
$mailbox->importRemaining = 0;
$mailbox->importPageAttempts = 0;
$this->em->flush();
$this->logger->info('Import: folder finished', [
'mailboxId' => $mailbox->id,
'mailbox' => $mailbox->fullPath,
'messages' => $mailbox->importTotal,
]);
}
}
+99 -1
View File
@@ -20,6 +20,7 @@ use App\Service\Mail\InlineAttachmentDetector;
use App\Service\Mail\MisfiledBodyDetector;
use App\Service\Mail\MisfiledBodyUnpacker;
use App\Service\Mail\PostIngestPipeline;
use App\Service\Monitoring\ProcessHeartbeatService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
@@ -56,8 +57,21 @@ class MessageSyncer
private readonly GhostMessageReaper $ghosts,
private readonly ManagerRegistry $registry,
private readonly ImapPagedFetch $pagedFetch,
private readonly ImapImportPlan $importPlan,
private readonly ProcessHeartbeatService $heartbeats,
) {}
/**
* Whether the page being stored is history rather than new mail — set for
* the length of storeImportPage() and at no other time.
*
* State on a service, which this class otherwise has none of, because the
* one thing it changes is four calls down: how a message that would not
* store is held (see holdForRetry()). Carried as an argument it would be a
* parameter on five signatures to reach one `if`.
*/
private bool $importing = false;
/**
* A UID range as a search criterion the library will not quote.
*
@@ -73,7 +87,7 @@ class MessageSyncer
* generate_query() appends verbatim. A single UID would have been fine
* either way, which is why this never showed up in a first sync of one.
*/
private static function uidRangeCriteria(string $uidRange): string
public static function uidRangeCriteria(string $uidRange): string
{
return 'CUSTOM UID '.$uidRange;
}
@@ -118,6 +132,16 @@ class MessageSyncer
// it only lists a folder every SWEEP_INTERVAL_MINUTES.
$this->vanished->sweep($mailbox, $client);
// A folder nobody has looked at yet: put the mark at its top, so what
// follows asks for new mail only, and leave everything below to the
// importer, which brings it in newest first on its own queue. Without
// this the range below was `1:*` — the whole folder, oldest first, in
// this one job (#42). A server that will not say where its top is
// leaves the folder unplanned, and it is read that old way.
if (null === $mailbox->importFloorUid) {
$this->importPlan->begin($mailbox, $client);
}
$lastSeenUid = $mailbox->lastSeenUid ?? 0;
$uidRange = ($lastSeenUid + 1) . ':*';
@@ -157,6 +181,7 @@ class MessageSyncer
$this->processBatch($batch, $mailboxId, $accountId, $lastSeenUid, $syncedUids, $lowestSkippedUid, $presence, $unreadable);
$synced += count($batch);
$this->startNextBatch();
$this->heartbeats->beatWhileBusy();
$this->logger->info(sprintf('Synced %d messages so far', $synced));
}
@@ -195,6 +220,66 @@ class MessageSyncer
$this->em->flush();
}
/**
* Store one page of a folder's history, fetched by ImapMailboxImporter.
*
* The same path new mail takes — processBatch(), the post-ingest pipeline
* and all — so an imported message is in every way a message. What differs
* is the bookkeeping around it: the page is below the high-water mark by
* definition, so the mark is not what says "already here" (nothing is
* passed as already synced; the importer asked only for what is missing),
* and a message that will not store is reported back rather than counted
* on the mailbox.
*
* @param iterable<ImapMessage> $batch
* @param array<int, \Throwable> $unreadable as for processBatch()
*
* @return int|null the lowest UID of the page that was held back, or null
* when every message of it was dealt with
*/
public function storeImportPage(Mailbox $mailbox, iterable $batch, array $unreadable): ?int
{
$syncedUids = [];
$lowestHeld = null;
$presence = new ImapUidPresence($mailbox->account, $this->connections, $this->logger);
$this->importing = true;
try {
$this->processBatch(
$batch,
(int) $mailbox->id,
(int) $mailbox->account->id,
0,
$syncedUids,
$lowestHeld,
$presence,
$unreadable,
);
} finally {
$this->importing = false;
$presence->close();
}
$this->startNextBatch();
return $lowestHeld;
}
/**
* A set of UIDs as a search criterion: `UID 912,915:917,930`.
*
* For the importer, which asks for exactly the messages a folder is
* missing rather than for a range. Through the same escape hatch as
* uidRangeCriteria(), for the same reason.
*
* @param list<int> $uids
*/
public static function uidSetCriteria(array $uids): string
{
return self::uidRangeCriteria(implode(',', $uids));
}
/**
* @param array<int,bool> $syncedUids passed by reference so new UIDs are
* registered within the same sync run
@@ -621,6 +706,19 @@ class MessageSyncer
*/
private function holdForRetry(Mailbox $mailbox, int $uid, ?int &$lowestSkippedUid): bool
{
// A page of history keeps no count here. The counter on the mailbox
// belongs to the top of the folder, where new mail arrives, and the
// importer is reading the bottom of the same folder from another
// worker: one ledger between them and a message failing at each end
// would take the count from the other on every pass. The importer
// counts by the page instead — see ImapMailboxImporter — and all it
// needs from here is to be told that something was held.
if (true === $this->importing) {
$lowestSkippedUid = null === $lowestSkippedUid ? $uid : min($lowestSkippedUid, $uid);
return true;
}
if (null !== $lowestSkippedUid && $lowestSkippedUid < $uid) {
return true;
}
@@ -369,6 +369,11 @@ final readonly class VanishedMessageReconciler
$mailbox->uidValidity = $uidValidity;
$mailbox->lastSeenUid = 0;
$mailbox->sweptAt = null;
// Unplanned, so the re-read is planned like a first one: new mail from
// the top at once, the rest a page at a time on the import queue,
// instead of the whole folder bottom-up in the job that noticed. Each
// row is still re-matched by its Message-ID as its page comes in.
$mailbox->importFloorUid = null;
$this->em->flush();
+30
View File
@@ -6,6 +6,7 @@ namespace App\Service\Job;
use App\Domain\Helper\ThrowableSeverity;
use App\Entity\Job\BackgroundJob;
use App\Entity\User\User;
use App\Infrastructure\Mercure\UserUpdate;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
@@ -27,6 +28,35 @@ final readonly class JobNotifier
) {
}
/**
* Tell a person's open pages to look at the background-work indicator
* again, without a job to point at.
*
* For an import, which shows in the same indicator and is not a job row:
* how far it has got is read off the account and its folders (see
* ImportProgress), so there is nothing to name here but the reader.
*/
public function nudge(User $user): void
{
if (null === $user->id) {
return;
}
try {
$this->hub->publish(UserUpdate::create(
topics: [sprintf('mail/user/%d', $user->id)],
data: json_encode(['type' => 'jobs.changed'], JSON_THROW_ON_ERROR),
));
} catch (\Throwable $e) {
// The doorbell, not the delivery — as for changed() below.
$this->logger->log(
ThrowableSeverity::level($e, LogLevel::WARNING),
'JobNotifier: publish failed',
['error' => $e->getMessage(), 'exception' => $e],
);
}
}
public function changed(BackgroundJob $job): void
{
$userId = $job->usr->id;
+40 -17
View File
@@ -81,28 +81,14 @@ final class GmailApiClient
*/
public function listMessages(Account $account, array $params = []): array
{
$token = $this->tokenManager->getValidAccessToken($account);
$messages = [];
$page = null;
do {
$query = $params;
$listed = $this->listMessagesPage($account, $params, $page);
$page = $listed['nextPageToken'];
if (null !== $page) {
$query['pageToken'] = $page;
}
$this->pacer->spend($account, GmailQuotaPacer::UNITS_PER_LIST_PAGE);
$response = $this->httpClient->request('GET', self::BASE . '/messages', [
'auth_bearer' => $token,
'query' => $query,
]);
$body = $this->decode($response, 'messages.list');
$page = $body['nextPageToken'] ?? null;
foreach ($body['messages'] ?? [] as $m) {
foreach ($listed['messages'] as $m) {
$messages[] = $m;
}
} while (null !== $page);
@@ -110,6 +96,43 @@ final class GmailApiClient
return $messages;
}
/**
* One page of a message listing, and the token for the page after it.
*
* listMessages() above walks every page before it answers, which for a
* mailbox of twenty thousand is forty requests and several minutes inside
* whichever job asked — the first thing a new account did, before a single
* message was fetched (#42). An import asks for a page, acts on it, and
* comes back for the next.
*
* @param array<string, mixed> $params
*
* @return array{messages: list<array<string, mixed>>, nextPageToken: ?string}
*/
public function listMessagesPage(Account $account, array $params = [], ?string $pageToken = null): array
{
$token = $this->tokenManager->getValidAccessToken($account);
if (null !== $pageToken) {
$params['pageToken'] = $pageToken;
}
$this->pacer->spend($account, GmailQuotaPacer::UNITS_PER_LIST_PAGE);
$response = $this->httpClient->request('GET', self::BASE . '/messages', [
'auth_bearer' => $token,
'query' => $params,
]);
$body = $this->decode($response, 'messages.list');
$next = $body['nextPageToken'] ?? null;
return [
'messages' => array_values($body['messages'] ?? []),
'nextPageToken' => is_string($next) && '' !== $next ? $next : null,
];
}
/**
* Fetch multiple messages (format=full) using the Gmail Batch API.
*
+87
View File
@@ -224,6 +224,93 @@ final class GraphApiClient
];
}
/**
* One page of a folder's first enumeration, newest mail first.
*
* deltaMessages() reads a delta round to its end, which is right for a
* round that carries the last quarter of an hour and wrong for the one
* that carries a folder's whole history: that is a listing of everything
* in it, made in one go by whoever asked. This is the same round a page at
* a time, for MailImporter — pass null to begin, and then whatever came
* back as `nextLink` until a `deltaLink` arrives instead. That link is the
* folder's, exactly as if deltaMessages() had been given null.
*
* `$orderby=receivedDateTime desc` is the one ordering a message delta
* accepts, and it is what puts the mail somebody is looking for on the
* first page instead of the last. The page size is a preference the server
* is free to round, so nothing may depend on it being exact.
*
* A link the server no longer honours answers `resyncRequired`, as it
* does for deltaMessages(): the enumeration starts again from null.
*
* @return array{items: list<array<string,mixed>>, nextLink: string|null, deltaLink: string|null, resyncRequired: bool}
*/
public function deltaMessagesPage(Account $account, string $folderId, ?string $nextLink, int $pageSize): array
{
$options = [
// One header, both preferences: request() merges by header name,
// and a second `Prefer` would replace the immutable-id one.
'headers' => ['Prefer' => sprintf('IdType="ImmutableId", odata.maxpagesize=%d', $pageSize)],
];
if (null !== $nextLink && '' !== $nextLink) {
$url = $nextLink;
} else {
$url = self::ME . '/mailFolders/' . rawurlencode($folderId) . '/messages/delta';
$options['query'] = [
'$select' => self::DELTA_SELECT,
'$orderby' => 'receivedDateTime desc',
];
}
try {
$body = $this->request($account, 'GET', $url, $options)->toArray();
} catch (GraphResyncRequiredException) {
return ['items' => [], 'nextLink' => null, 'deltaLink' => null, 'resyncRequired' => true];
}
$next = $body['@odata.nextLink'] ?? null;
$delta = $body['@odata.deltaLink'] ?? null;
return [
'items' => array_values($body['value'] ?? []),
'nextLink' => is_string($next) && '' !== $next ? $next : null,
'deltaLink' => is_string($delta) && '' !== $delta ? $delta : null,
'resyncRequired' => false,
];
}
/**
* The newest messages a folder received since a moment, in the delta
* projection and at most `$limit` of them.
*
* For a folder that has no delta link yet because its history is still
* being read. Such a folder cannot be asked what changed, but it can be
* asked what arrived, and that is the half that cannot wait for an import
* to end. One request and one page: more than `$limit` new messages in a
* folder between two syncs is the import's to find.
*
* @return list<array<string,mixed>>
*/
public function listReceivedSince(Account $account, string $folderId, \DateTimeImmutable $since, int $limit): array
{
$body = $this->request(
$account,
'GET',
self::ME . '/mailFolders/' . rawurlencode($folderId) . '/messages',
['query' => [
'$select' => self::DELTA_SELECT,
// Filter and order on the same property, in that order: Graph
// refuses a sort on a property the filter does not lead with.
'$filter' => 'receivedDateTime ge ' . $since->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'),
'$orderby' => 'receivedDateTime desc',
'$top' => $limit,
]],
)->toArray();
return array_values($body['value'] ?? []);
}
/**
* Fetch full message resources for up to BATCH_LIMIT ids in one $batch POST.
*
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\Mail\ImportLine;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MailboxRepository;
use App\Repository\Mail\MessageRepository;
use App\Service\Graph\GraphFolderResolver;
use DateTimeImmutable;
/**
* Which of a person's accounts are still bringing in their history, and how
* far each has got.
*
* What the topbar's background-work indicator shows while an import runs. An
* import used to be invisible: mail appeared in bursts with nothing to say
* whether the gaps between them were work or a fault (#42).
*
* Read off the same state the import reads — the folders' markers for IMAP,
* the backfill state for Gmail, the list of folders still to be read for
* Microsoft, and for those two a count of what is stored — so it cannot
* say one thing while the import does another, and costs nothing to keep.
*
* CHEAP WHEN NOTHING IS IMPORTING, which is nearly always, because the topbar
* asks on every page: a person's accounts are one query, and whether any of
* their folders has history left is one more for all of them together. The
* per-account queries only run for an account that is actually importing.
*/
final readonly class ImportProgress
{
/**
* Seconds without a sign of life after which a line says "waiting". Twice
* the interval at which a stopped import is started again, so one that is
* merely between two pages never shows it.
*/
private const int WAITING_AFTER_SECONDS = MailImporter::QUIET_SECONDS * 2;
public function __construct(
private AccountRepository $accounts,
private MailboxRepository $mailboxes,
private MessageRepository $messages,
private GraphFolderResolver $folders,
) {}
/** @return list<ImportLine> one per importing account, in the accounts' own order */
public function forUser(User $user): array
{
/** @var list<Account> $accounts */
$accounts = $this->accounts->findBy(['usr' => $user, 'isActive' => true], ['sortOrder' => 'ASC', 'id' => 'ASC']);
if ([] === $accounts) {
return [];
}
$folders = $this->mailboxes->importTotalsByAccount($accounts);
$lines = [];
foreach ($accounts as $account) {
if (true === $account->isMicrosoft()) {
$plan = $account->graphImport;
if ([] !== $plan) {
$lines[] = new ImportLine(
$account,
$this->messages->countForAccount($account),
$account->importTotal,
null,
$this->isWaiting($account),
$this->folders->resolveFolder($plan[0]['folder'], $account),
);
}
continue;
}
if (true === $account->isGmail()) {
if (true === $account->needsBackfill()) {
$lines[] = new ImportLine(
$account,
$this->messages->countForAccount($account),
$account->importTotal,
null,
$this->isWaiting($account),
);
}
continue;
}
$totals = $folders[(int) $account->id] ?? null;
if (null === $totals || 0 === $totals['importing']) {
continue;
}
$lines[] = new ImportLine(
$account,
max(0, $totals['total'] - $totals['remaining']),
0 < $totals['total'] ? $totals['total'] : null,
$this->mailboxes->findImporting($account)[0] ?? null,
$this->isWaiting($account),
);
}
return $lines;
}
private function isWaiting(Account $account): bool
{
$beatAt = $account->importBeatAt;
return null === $beatAt
|| $beatAt < new DateTimeImmutable(sprintf('-%d seconds', self::WAITING_AFTER_SECONDS));
}
}
+4 -3
View File
@@ -30,7 +30,8 @@ use App\Repository\Mail\MailboxRepository;
* Gmail has real backfill state. needsBackfill() is false only once a
* listing has found nothing left to fetch — see GmailApiSyncer.
* Graph keeps a delta link per folder, written once that folder has been
* enumerated. None at all means no folder has been.
* enumerated, and a list of the folders still to be (see
Account::$graphImport). No link at all means no sync has run.
* IMAP has neither, but every folder gets a syncedAt when its first pass
* ends, so a sync-enabled folder without one is still importing.
*
@@ -40,7 +41,7 @@ use App\Repository\Mail\MailboxRepository;
* not held, so it may move tabs once. The opposite mistake — calling an import
* finished early — would hold a mailbox's worth of mail and queue it as live.
*
* A folder discovered later makes an IMAP account "importing" again until that
* A folder discovered later makes an IMAP or Microsoft account "importing" again until that
* folder has been read, which is correct rather than a quirk: what is about to
* arrive from it is a folder's history, not new mail.
*/
@@ -57,7 +58,7 @@ final readonly class InitialImportState
}
if (true === $account->isMicrosoft()) {
return [] !== $account->graphDeltaLinks;
return [] !== $account->graphDeltaLinks && false === $account->needsGraphImport();
}
// Read from the database rather than off $account->mailboxes: the
+268
View File
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Enum\Mail\SyncTrigger;
use App\Domain\Helper\ImapConnectionFactory;
use App\Entity\Mail\Account;
use App\Infrastructure\Messaging\Message\ImportMailMessage;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MailboxRepository;
use App\Service\Gmail\GmailApiSyncer;
use App\Service\Gmail\GmailBackfillStep;
use App\Service\Graph\GraphApiSyncer;
use App\Service\Graph\GraphImportStep;
use App\Service\Imap\ImapImportOutcome;
use App\Service\Imap\ImapMailboxImporter;
use App\Service\Job\JobNotifier;
use App\Service\Monitoring\ProcessHeartbeatService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
/**
* An account's history, brought in a page at a time without ever being in
* front of the mail that is arriving now.
*
* A first sync used to BE the import: one job per account that read every
* folder to its end, oldest mail first, on the queue new mail arrives on. An
* account with twenty thousand messages held that queue for as long as it
* took, showed an empty Inbox for most of it, and made its worker look dead
* while it worked (#42). The sync now only arranges for new mail to be seen
* and hands the history to this.
*
* ## The shape
*
* One ImportMailMessage is one page: fifty messages of an IMAP or Microsoft
* folder, or one page of a Gmail listing. Its handler ends by dispatching the next. Nothing
* here is a loop, and that is the design:
*
* - **Accounts take turns.** Each importing account has one message on the
* queue at a time, so three accounts' pages interleave in arrival order
* and each has a usable Inbox before any has its archive.
* - **Within an account the Inbox leads.** MailboxRepository::findImporting()
* orders the folders; the first is read until it is done. Its first four
* pages are the newest two hundred messages.
* - **Nothing holds the worker.** A page is seconds, so the heartbeat that
* beats between jobs is enough, and a restart loses one page.
*
* It all runs on `ingest_backlog`, with a worker of its own — see
* messenger.yaml for why a second queue on the same worker would not do.
*
* ## The safety net
*
* A chain of messages is only as good as its weakest link: a page that fails
* through every retry takes the chain with it. So every ordinary sync of an
* account ends by calling ensureRunning(), which starts a chain if the account
* still has history to fetch and nothing has touched its import for
* QUIET_SECONDS. "Has anything touched it" is Account::$importBeatAt, which
* each page stamps — the same fact the progress line reads to say an import
* has stalled.
*
* Two chains for one account can therefore exist, briefly, when a page is slow
* to arrive. That is left alone: the worker takes one message at a time, a
* page asks the server what is missing before it fetches, and the worst of it
* is an account importing a little faster than its neighbours.
*
* ## Three providers, one chain
*
* What a page IS differs — fifty messages of an IMAP folder, a page of a
* Gmail listing, a page of a Microsoft folder's enumeration — and each
* provider's own class knows how to take one. What is the same is here: whose
* turn it is, when to ask again, and who is told.
*/
final readonly class MailImporter
{
/** Seconds of silence after which an unfinished import is started again. */
public const int QUIET_SECONDS = 300;
/**
* How long to leave a page that could not go through before asking again.
* A retry is the same messages a second time, and a folder the server
* will not open is not going to open in the next second.
*/
private const int WAIT_MS = 60_000;
public function __construct(
private AccountRepository $accounts,
private MailboxRepository $mailboxes,
private ImapMailboxImporter $imapImporter,
private ImapConnectionFactory $connections,
private GmailApiSyncer $gmail,
private GraphApiSyncer $graph,
private SyncNotifier $notifier,
private JobNotifier $jobs,
private ProcessHeartbeatService $heartbeats,
private SyncOrigin $origin,
private MessageBusInterface $bus,
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {}
/** Whether this account still has history that this class would fetch. */
public function hasWork(Account $account): bool
{
if (true !== $account->isActive) {
return false;
}
if (true === $account->isMicrosoft()) {
return $account->needsGraphImport();
}
if (true === $account->isGmail()) {
return $account->needsBackfill();
}
return [] !== $this->mailboxes->findImporting($account);
}
/**
* Start this account's import if it has one to do and nothing is doing it.
*
* Called at the end of every sync, which makes it both the thing that
* starts an import — the first sync of a new account has just planned its
* folders — and the thing that restarts one whose chain broke.
*/
public function ensureRunning(Account $account): void
{
if (false === $this->hasWork($account)) {
return;
}
$beatAt = $account->importBeatAt;
if (null !== $beatAt && $beatAt > new DateTimeImmutable(sprintf('-%d seconds', self::QUIET_SECONDS))) {
return;
}
$this->beat($account);
$this->bus->dispatch(new ImportMailMessage((int) $account->id));
$this->logger->info('Import: started', ['accountId' => $account->id]);
}
/**
* One page of this account's history, and the request for the next.
*/
public function runPage(int $accountId): void
{
$account = $this->accounts->find($accountId);
if (null === $account || false === $this->hasWork($account)) {
// The chain ends here: nothing left, or nobody to import for.
return;
}
$this->beat($account);
$this->heartbeats->beatWhileBusy();
// Imported mail is marked as such, like mail a poll brought in: it is
// not something a person was just sent.
$wait = false;
$this->origin->during(SyncTrigger::Poll, function () use ($account, &$wait): void {
$wait = match (true) {
$account->isGmail() => $this->gmailPage($account),
$account->isMicrosoft() => GraphImportStep::Waiting === $this->graph->importPage($account),
default => $this->imapPage($account),
};
});
// A page clears the entity manager; this is the account as it is now.
$account = $this->accounts->find($accountId);
if (null === $account) {
return;
}
$this->beat($account);
$this->jobs->nudge($account->usr);
if (false === $this->hasWork($account)) {
$this->logger->info('Import: finished', ['accountId' => $accountId]);
return;
}
// Gmail between listings has nothing to ask for until the last one's
// batches have drained; the chain stops and ensureRunning() picks it
// up again from a later sync.
if (true === $account->isGmail() && true === $wait) {
return;
}
$this->bus->dispatch(
new ImportMailMessage($accountId),
true === $wait ? [new DelayStamp(self::WAIT_MS)] : [],
);
}
/** @return bool whether asking again at once would only repeat this */
private function gmailPage(Account $account): bool
{
// The very first page of the very first listing: the Inbox by name,
// ahead of the mailbox at large.
if (null === $account->backfillRanAt) {
$this->gmail->importInboxFirst($account);
}
return GmailBackfillStep::More !== $this->gmail->backfillPage($account);
}
/** @return bool whether asking again at once would only repeat this */
private function imapPage(Account $account): bool
{
$accountId = (int) $account->id;
$mailboxIds = array_map(
static fn ($mailbox): int => (int) $mailbox->id,
$this->mailboxes->findImporting($account),
);
$client = $this->connections->connect($account);
$outcome = ImapImportOutcome::Waiting;
try {
// The first folder that can be read. One the server will not open
// just now is passed over rather than waited on: it is first in
// the list by rank, and waiting on it would hold every folder
// behind it for as long as it stays shut.
foreach ($mailboxIds as $mailboxId) {
$outcome = $this->imapImporter->importPage($mailboxId, $client);
if (ImapImportOutcome::Waiting !== $outcome) {
$this->published($accountId, $mailboxId);
break;
}
}
} finally {
$client->disconnect();
}
return $outcome->shouldWait();
}
/** Tell the open pages of this account's owner that a folder has more in it. */
private function published(int $accountId, int $mailboxId): void
{
$account = $this->accounts->find($accountId);
$mailbox = $this->mailboxes->find($mailboxId);
if (null !== $account && null !== $mailbox) {
$this->notifier->publishMailboxSynced($account, $mailbox);
}
}
private function beat(Account $account): void
{
$account->importBeatAt = new DateTimeImmutable();
$this->em->flush();
}
}
+15 -9
View File
@@ -52,7 +52,9 @@ final class SmtpDsnFactory
'%s://%s:%s@%s:%d',
$scheme,
rawurlencode((string) $account->username),
rawurlencode((string) $account->password),
// The sending password, which is the account's own unless it has
// been given a separate one. See Account::$sendingPassword.
rawurlencode((string) $account->sendingPassword),
MailServerHost::forAuthority((string) $account->smtpHost),
$port,
);
@@ -64,16 +66,20 @@ final class SmtpDsnFactory
*/
public function redact(string $text, Account $account): string
{
$password = (string) $account->password;
// Both, where there are two: the DSN carries the sending one, and an
// IMAP error quoted in the same report may carry the other.
foreach (array_unique([(string) $account->sendingPassword, (string) $account->password]) as $password) {
if ('' === $password) {
continue;
}
if ('' === $password) {
return $text;
$text = str_replace(
[$password, urlencode($password), rawurlencode($password)],
'***',
$text,
);
}
return str_replace(
[$password, urlencode($password), rawurlencode($password)],
'***',
$text,
);
return $text;
}
}
@@ -85,6 +85,8 @@ final readonly class BackgroundProcesses
* The order within a row is the priority — Messenger drains an earlier
* transport before it looks at a later one:
*
* worker-backlog a mailbox's history, a page at a time, so that mail
* arriving now is never behind it on worker-ingest
* worker-live mail that has just arrived, and nothing else, so that
* it is never behind an import
* worker-release ends holds, and is otherwise idle on purpose
@@ -100,6 +102,7 @@ final readonly class BackgroundProcesses
return [
'worker-export' => ['export'],
'worker-ingest' => ['ingest'],
'worker-backlog' => ['ingest_backlog'],
'worker-live' => ['enrich_live'],
'worker-release' => ['release'],
'worker-enrich' => ['enrich', 'enrich_backlog'],
+19
View File
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Twig;
use App\Entity\Job\BackgroundJob;
use App\Domain\DTO\Mail\ImportLine;
use App\Entity\User\User;
use App\Repository\Job\BackgroundJobRepository;
use App\Service\Mail\ImportProgress;
use Symfony\Bundle\SecurityBundle\Security;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
@@ -28,6 +31,7 @@ final class BackgroundJobsExtension extends AbstractExtension
public function __construct(
private readonly Security $security,
private readonly BackgroundJobRepository $jobs,
private readonly ImportProgress $imports,
) {
}
@@ -35,6 +39,7 @@ final class BackgroundJobsExtension extends AbstractExtension
{
return [
new TwigFunction('background_jobs', $this->forCurrentUser(...)),
new TwigFunction('mail_imports', $this->importsForCurrentUser(...)),
];
}
@@ -49,4 +54,18 @@ final class BackgroundJobsExtension extends AbstractExtension
return $this->jobs->findVisibleForUser($user);
}
/**
* The accounts of the person looking that are still importing — shown in
* the same indicator as the jobs above, and for the same reason asked
* here rather than fetched by the page. See ImportProgress.
*
* @return list<ImportLine>
*/
public function importsForCurrentUser(): array
{
$user = $this->security->getUser();
return $user instanceof User ? $this->imports->forUser($user) : [];
}
}
+9 -1
View File
@@ -36,7 +36,15 @@ dozen other reasons while the conversation was open.
mail--mail-pane is: it is about a mail LIST. It listens on <body> because
the two ends of a drag — the rows in the list frame and the folders in the
sidebar, of which there are two copies — have no closer common ancestor. #}
{% block body_controllers %}core--mercure mail--mail-pane mail--dnd{% endblock %}
{# `parent()`, and not the base list written out again. Written out, it was
one controller short: the base layout mounts `mail--jobs` beside
`core--mercure`, this block replaced both names with its own three, and the
topbar's background-work indicator was never told to look again on any mail
page — which is every page a bulk action is started from. It showed what the
server rendered at page load and then sat there, for as long as it existed
(#42 is where it was finally noticed). Whatever the base layout mounts, the
mailbox mounts too. #}
{% block body_controllers %}{{ parent() }} mail--mail-pane mail--dnd{% endblock %}
{% block body_values %}
{# The list to draw beside a message that was opened by its own address.
+17 -13
View File
@@ -104,7 +104,7 @@
`.search-shell` in app.css), so what is left in this group is two icons
that belong beside the account controls rather than adrift in the
middle of an empty row. #}
<div class="flex-1 max-w-2xl mx-auto flex items-center gap-2 min-w-0 max-md:justify-end"
<div class="flex-1 max-w-2xl mx-auto flex items-center gap-2 min-w-0 max-md:justify-end xl:relative"
data-controller="ui--mobile-search"
{# On the controller element, where Stimulus reads values from — not on
the button, which is only a target. Both labels are rendered here
@@ -132,6 +132,22 @@
<i class="fa-solid fa-magnifying-glass text-base" aria-hidden="true"></i>
</button>
{# Background work, and nothing at all when there is none.
A whole-view action runs on a worker now — the request that used to
mark five thousand conversations read inline died at thirty seconds —
so this is the only place the user can see it happening. The frame
renders empty when there are no jobs, which is why there is no
wrapper or spacing around it: an indicator that is always present is
another fixture to learn to ignore.
Directly left of the search box, which is where the eye is: it
was the first of six muted icons at the far right, and nobody saw
it there. #}
{% if app.user %}
{{ include('mail/_jobs_indicator.html.twig', { jobs: background_jobs(), imports: mail_imports() }) }}
{% endif %}
{# The search box is a combobox in the ARIA sense — a text field with a
popup list attached — and the parts of that live in three places: the
input carries the state, the listbox carries the options, and the
@@ -479,18 +495,6 @@
{# Right actions #}
<div class="flex items-center gap-1 flex-shrink-0">
{# Background work, and nothing at all when there is none.
A whole-view action runs on a worker now — the request that used to
mark five thousand conversations read inline died at thirty seconds —
so this is the only place the user can see it happening. The frame
renders empty when there are no jobs, which is why there is no
wrapper or spacing around it: an indicator that is always present is
another fixture to learn to ignore. #}
{% if app.user %}
{{ include('mail/_jobs_indicator.html.twig', { jobs: background_jobs() }) }}
{% endif %}
{# Sync now: the same account syncs as app:mail:sync, for this user (SyncController). #}
<button
type="button"
+44
View File
@@ -79,6 +79,50 @@ genuinely needed, merge onto form.<field>.vars.attr.
{{ form_row(form.smtpPort, { label: 'account.form.port'|trans }) }}
</div>
{{ form_row(form.smtpEncryption, { label: 'account.form.encryption'|trans }) }}
{# ── A different password for sending ──
Nearly every server takes one password for both directions, so this is
one line of text until somebody asks for more: a button, which ticks
the (unseen) box and reveals the field. See AccountType for why the box
and not the field is what the server believes.
The box is rendered by the form and hidden with the `hidden` attribute,
not left out and recreated by script: after a failed validation the
form comes back with the box as it was submitted, and the controller
reads its state from there on connect, so the second field does not
fold itself away with what was typed in it. #}
<div data-controller="settings--smtp-password">
{{ form_widget(form.separateSmtpPassword, { attr: {
hidden: 'hidden',
tabindex: '-1',
'data-settings--smtp-password-target': 'separate',
} }) }}
<button type="button"
data-settings--smtp-password-target="offer"
data-action="click->settings--smtp-password#separate"
class="inline-flex items-center gap-1.5 text-xs text-ink-muted hover:text-ink hover:underline cursor-pointer">
<i class="fa-solid fa-key text-[10px]" aria-hidden="true"></i>
{{ 'account.form.smtp.separate'|trans }}
</button>
<div data-settings--smtp-password-target="fields" hidden class="space-y-2">
{{ form_row(form.smtpPassword, {
label: 'account.form.smtp.password'|trans,
help: mode == 'edit' and form.separateSmtpPassword.vars.checked ? 'settings.accounts.password_help'|trans : null,
attr: form.smtpPassword.vars.attr|merge({
placeholder: '••••••••',
'data-settings--smtp-password-target': 'password',
})
}) }}
<button type="button"
data-action="click->settings--smtp-password#same"
class="inline-flex items-center gap-1.5 text-xs text-ink-muted hover:text-ink hover:underline cursor-pointer">
<i class="fa-solid fa-xmark text-[10px]" aria-hidden="true"></i>
{{ 'account.form.smtp.same'|trans }}
</button>
</div>
</div>
</div>
<div class="border-t border-line"></div>
+1 -1
View File
@@ -29,7 +29,7 @@ Variables: job
and the point of this response is that something appears NOW. #}
<turbo-stream action="replace" target="jobs-indicator">
<template>
{{ include('mail/_jobs_indicator.html.twig', { jobs: [job] }) }}
{{ include('mail/_jobs_indicator.html.twig', { jobs: [job], imports: mail_imports() }) }}
</template>
</turbo-stream>
+92 -13
View File
@@ -30,26 +30,65 @@ the record and a missed nudge costs one stale render, never a wrong answer.
it is already making a page render for. From then on it is replaced: by the
response to starting a job, and by mail--jobs when Mercure says one moved.
The route stays, because that is what mail--jobs re-reads. #}
<turbo-frame id="jobs-indicator">
{% if jobs is defined and jobs|length > 0 %}
{% set active = jobs|filter(j => j.isActive())|length %}
{# An import is shown here too, and is not a job: `imports` is a list of
App\Domain\DTO\Mail\ImportLine, one per account still bringing in its
history, worked out from the account each time (see ImportProgress). It
counts as something running, so the button is there and moving for as long
as mail is still arriving from the past. #}
{% set jobs = jobs|default([]) %}
{% set imports = imports|default([]) %}
{# `contents`, so an empty frame is no box at all: as a flex item it would
still claim a gap beside the search box and push it off centre. #}
<turbo-frame id="jobs-indicator" class="contents">
{% if jobs|length > 0 or imports|length > 0 %}
{% set active = jobs|filter(j => j.isActive())|length + imports|filter(i => false == i.waiting)|length %}
{% set failed = jobs|filter(j => j.state.value == 'failed')|length %}
<div class="relative" data-controller="ui--dropdown">
{# From xl up it hangs off the left edge of the search group instead
of sitting in it, so the search box stays in the middle of the
topbar whether or not anything is running. Narrower than that there
is no room to its left and it takes a place in the row as an icon. #}
<div class="relative xl:absolute xl:right-full xl:top-1/2 xl:-translate-y-1/2 xl:mr-2" data-controller="ui--dropdown">
{# A tinted chip with words on it, not a bare icon. It used to be
a muted glyph that faded in and out — from barely visible to
nearly invisible — beside five other muted glyphs. Tinted rather
than filled: a solid block of accent beside the search box
outshouted Compose, and this is a notice, not an action. The
colour says which of three things is true: running (accent),
failed (danger), or stopped with something to read (plain).
The label goes below xl, where the chip is in the row and has
no room for it; the chip and its colour stay. #}
{% set importing = imports|filter(i => false == i.waiting)|first %}
{% if failed > 0 %}
{% set tone = 'border border-danger/40 bg-danger/10 text-danger hover:bg-danger/15' %}
{% set label = 'jobs.button.failed'|trans %}
{% elseif active > 0 %}
{% set tone = 'border border-accent/40 bg-accent-soft text-accent hover:bg-accent/20' %}
{% set label = importing
? (importing.total is not null
? 'jobs.button.importing_percent'|trans({ '%percent%': importing.percent() })
: 'jobs.button.importing'|trans)
: 'jobs.button.working'|trans %}
{% else %}
{% set tone = 'border border-line text-ink-muted hover:bg-hover' %}
{% set label = imports|length > 0 ? 'jobs.import.waiting'|trans : 'jobs.heading'|trans %}
{% endif %}
<button
type="button"
data-action="click->ui--dropdown#toggle"
class="flex cursor-pointer items-center justify-center w-9 h-9 rounded-lg
text-ink-muted hover:bg-hover transition-colors flex-shrink-0"
data-jobs-button
class="flex cursor-pointer items-center justify-center gap-1.5 h-8 px-2.5 max-xl:w-8 max-xl:px-0 rounded-lg
text-xs font-medium whitespace-nowrap transition-colors flex-shrink-0 {{ tone }}"
aria-label="{{ 'jobs.heading'|trans }}"
title="{{ 'jobs.heading'|trans }}"
>
{# Spinning only while something is actually running: a static
glyph for "finished, here is what happened" and a moving one
for "still going" is the difference the eye reads first. #}
<i class="fa-solid fa-list-check text-base {{ active > 0 ? 'fa-fade' : '' }}
{{ failed > 0 ? 'text-danger' : (active > 0 ? 'text-accent' : '') }}"
{# Turning only while something is actually running: a still
glyph for "here is what happened" and a moving one for
"still going" is the difference the eye reads first. #}
<i class="fa-solid {{ failed > 0 ? 'fa-triangle-exclamation' : (active > 0 ? 'fa-arrows-rotate fa-spin' : 'fa-list-check') }} text-xs"
aria-hidden="true"></i>
<span class="max-xl:hidden">{{ label }}</span>
</button>
{# The `hidden` ATTRIBUTE, not the Tailwind class.
@@ -65,13 +104,53 @@ the record and a missed nudge costs one stale render, never a wrong answer.
data-ui--dropdown-target="menu"
data-enter="slide-down"
hidden
class="absolute right-0 top-full mt-1 z-30 w-80
rounded-pane bg-pane-soft backdrop-blur-sm
class="absolute left-0 max-md:left-auto max-md:right-0 top-full mt-1 z-30 w-80 text-left
rounded-pane bg-surface
border border-line shadow-pop p-2 text-sm"
>
<p class="px-2 py-1 text-xs font-semibold text-ink">{{ 'jobs.heading'|trans }}</p>
<ul class="mt-1 space-y-1">
{# Imports lead: they are the long ones, and the answer to
"why is my Inbox still filling up". #}
{% for line in imports %}
<li class="rounded-lg px-2 py-2 {{ line.waiting ? '' : 'bg-accent-soft/40' }}" data-import-line>
<div class="flex items-baseline justify-between gap-2">
<span class="text-xs font-medium text-ink truncate">
{{ 'jobs.import.heading'|trans({ '%account%': line.account.displayAddress }) }}
</span>
<span class="text-[11px] text-ink-faint tabular-nums shrink-0">
{% if line.total is not null %}
{{ line.done|number_format }} / {{ line.total|number_format }}
{% else %}
{{ 'jobs.state.queued'|trans }}
{% endif %}
</span>
</div>
{% if line.total is not null %}
<div class="mt-1.5 h-1 rounded-full bg-line overflow-hidden">
<div class="h-full bg-accent transition-all duration-300"
style="width: {{ line.percent() }}%"></div>
</div>
{% endif %}
{# What it is on, or that it is not on anything. A
bar that has stopped says nothing by itself;
"waiting" at least says it is known to have. #}
<p class="mt-1 text-[11px] text-ink-faint truncate">
{% if line.waiting %}
{{ 'jobs.import.waiting'|trans }}
{% elseif line.label %}
{{ 'jobs.import.folder'|trans({ '%folder%': line.label|label_name }) }}
{% elseif line.folder %}
{{ 'jobs.import.folder'|trans({ '%folder%': line.folder.label ? line.folder.label|label_name : line.folder.name }) }}
{% else %}
{{ 'jobs.import.newest_first'|trans }}
{% endif %}
</p>
</li>
{% endfor %}
{% for job in jobs %}
<li class="rounded-lg px-2 py-2 {{ job.isActive() ? 'bg-accent-soft/40' : '' }}">
<div class="flex items-baseline justify-between gap-2">
+41
View File
@@ -144,6 +144,47 @@ final class PageRendersTest extends WebTestCase
self::assertSame(200, $client->getResponse()->getStatusCode(), $path);
}
/**
* The topbar's background-work indicator is told to look again by a
* controller on <body>, and a layout that replaces the body's controller
* list has to keep it.
*
* The mailbox layout did not. It wrote its own three names over the base
* list, the indicator's controller was not among them, and on every mail
* page — where every bulk action is started — the indicator showed what
* the server had rendered and never changed again (#42). A string
* comparison, like AccountFormControllersTest and for its reason: nothing
* else notices a controller that is simply not mounted.
*
* @return iterable<string, array{string}>
*/
public static function pagesWithATopbar(): iterable
{
yield 'a mail list' => ['/mail/inbox'];
yield 'settings' => ['/settings?section=profile'];
}
#[DataProvider('pagesWithATopbar')]
public function testTheBackgroundWorkIndicatorIsMountedWhereverThereIsATopbar(string $path): void
{
$client = static::createClient();
$user = static::getContainer()->get(UserRepository::class)
->findOneBy(['email' => self::ADMIN_EMAIL]);
if (null === $user) {
self::markTestSkipped('run `app:test:seed-user --admin` first');
}
$client->loginUser($user);
$controllers = (string) $client->request('GET', $path)->filter('body')->attr('data-controller');
self::assertMatchesRegularExpression('/\bmail--jobs\b/', $controllers, $path);
// Presence: the page's own controllers are still there beside it.
self::assertMatchesRegularExpression('/\bcore--mercure\b/', $controllers, $path);
}
/**
* No page renders a select the browser will draw its own popup for.
*
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\Mail\Account;
use App\Form\AccountType;
use App\Service\Mail\SmtpDsnFactory;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
/**
* An account has one password unless somebody asks for a second, for sending.
*
* Some servers want a different password to send than to read — Zoho issues an
* app password per protocol — and until there was somewhere to put it such an
* account could be added, would sync, and failed every send on authentication
* (#42). The form shows one field and a button; the button ticks a box and
* reveals the second.
*
* What is pinned here is that the BOX decides and the field does not. A
* password manager fills a password input whether or not it is on screen, and
* a blank field on the edit form means "keep what is stored", so neither a
* value nor a blank says whether the account is meant to have two passwords.
*
* Through the real form type and the real factory, because the rule lives in
* the form's own listeners and a submitted array is the only honest input.
*/
final class AccountSendingPasswordTest extends KernelTestCase
{
private const array FIELDS = [
'email' => 'someone@example.test',
'username' => 'someone@example.test',
'password' => 'the-reading-password',
'imapHost' => 'imap.example.test',
'imapPort' => '993',
'imapEncryption' => 'ssl',
'smtpHost' => 'smtp.example.test',
'smtpPort' => '465',
'smtpEncryption' => 'ssl',
];
public function testWithoutTheBoxThereIsOnePasswordWhateverTheHiddenFieldWasSentWith(): void
{
$account = new Account();
// A password manager's doing: the field is not on screen, and it was
// filled anyway.
$this->form($account)->submit(self::FIELDS + ['smtpPassword' => 'filled-in-by-a-password-manager']);
self::assertNull($account->smtpPassword);
self::assertSame('the-reading-password', $account->sendingPassword);
}
public function testWithTheBoxTheSecondPasswordIsStoredAndIsWhatSends(): void
{
$account = new Account();
$this->form($account)->submit(self::FIELDS + [
'separateSmtpPassword' => '1',
'smtpPassword' => 'the-sending-password',
]);
self::assertSame('the-sending-password', $account->smtpPassword);
self::assertSame('the-reading-password', $account->password, 'the first one is untouched');
$dsn = static::getContainer()->get(SmtpDsnFactory::class)->forAccount($account);
self::assertStringContainsString('the-sending-password', $dsn);
self::assertStringNotContainsString('the-reading-password', $dsn);
}
/** The edit form's rule for the first password, kept for the second. */
public function testOnEditABlankFieldWithTheBoxStillTickedKeepsTheStoredOne(): void
{
$account = $this->saved();
$account->smtpPassword = 'stored-sending-password';
$form = $this->form($account, false);
self::assertTrue($form->get('separateSmtpPassword')->getData(), 'an account that has one opens with the box ticked');
$form->submit(self::FIELDS + ['separateSmtpPassword' => '1', 'smtpPassword' => '']);
self::assertSame('stored-sending-password', $account->smtpPassword);
}
public function testOnEditClearingTheBoxGoesBackToOnePassword(): void
{
$account = $this->saved();
$account->smtpPassword = 'stored-sending-password';
$this->form($account, false)->submit(self::FIELDS + ['smtpPassword' => '']);
self::assertNull($account->smtpPassword);
self::assertSame($account->password, $account->sendingPassword);
}
/** Presence for the two above: a new value with the box ticked replaces the stored one. */
public function testOnEditANewValueReplacesTheStoredOne(): void
{
$account = $this->saved();
$account->smtpPassword = 'stored-sending-password';
$this->form($account, false)->submit(self::FIELDS + [
'separateSmtpPassword' => '1',
'smtpPassword' => 'a-new-sending-password',
]);
self::assertSame('a-new-sending-password', $account->smtpPassword);
}
public function testAnAccountWithoutOneOpensWithTheBoxClear(): void
{
self::assertFalse($this->form($this->saved(), false)->get('separateSmtpPassword')->getData());
}
/** An error that quotes the address it failed on must not print either password. */
public function testBothPasswordsAreKeptOutOfAnErrorThatQuotesThem(): void
{
$account = $this->saved();
$account->smtpPassword = 'the-sending-password';
$redacted = static::getContainer()->get(SmtpDsnFactory::class)->redact(
'smtp said no to the-sending-password and imap to the-reading-password',
$account,
);
self::assertSame('smtp said no to *** and imap to ***', $redacted);
}
/** @return FormInterface<Account> */
private function form(Account $account, bool $requirePassword = true): FormInterface
{
return static::getContainer()->get(FormFactoryInterface::class)->create(AccountType::class, $account, [
'csrf_protection' => false,
'require_password' => $requirePassword,
]);
}
private function saved(): Account
{
$account = new Account();
$account->authType = 'password';
$account->email = self::FIELDS['email'];
$account->username = self::FIELDS['username'];
$account->password = self::FIELDS['password'];
$account->imapHost = self::FIELDS['imapHost'];
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->smtpHost = self::FIELDS['smtpHost'];
$account->smtpPort = 465;
$account->smtpEncryption = 'ssl';
return $account;
}
}
@@ -198,7 +198,8 @@ final class ClassificationCatchUpTest extends KernelTestCase
private function importFinished(): void
{
$this->mailbox->syncedAt = new DateTimeImmutable('-1 day');
$this->mailbox->syncedAt = new DateTimeImmutable('-1 day');
$this->mailbox->importFloorUid = 0;
$this->em->flush();
}
@@ -183,6 +183,7 @@ final class ConfigBackupRoundTripTest extends KernelTestCase
// ── The account ───────────────────────────────────────────────────
$this->check('account.password (IMAP)', self::MAILBOX_PASSWORD, $account->password);
$this->check('account.smtpPassword', 'the-smtp-password-4c1e', $account->smtpPassword);
$this->check('account.imapHost', 'imap.audit.test', $account->imapHost);
$this->check('account.colorIndex', 3, $account->colorIndex);
$this->check('account.oauthGrantedScopes', 'https://mail.google.com/ openid', $account->oauthGrantedScopes);
@@ -407,6 +408,7 @@ final class ConfigBackupRoundTripTest extends KernelTestCase
$account->email = 'anna@work.audit.test';
$account->username = 'anna@work.audit.test';
$account->password = self::MAILBOX_PASSWORD;
$account->smtpPassword = 'the-smtp-password-4c1e';
$account->imapHost = 'imap.audit.test';
$account->imapPort = 993;
$account->smtpHost = 'smtp.audit.test';
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Gmail;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Infrastructure\Messaging\Message\SyncGmailMessageBatchMessage;
use App\Repository\Mail\MessageRepository;
use App\Service\Gmail\GmailApiSyncer;
use App\Service\Gmail\GmailBackfillStep;
use App\Service\Gmail\GmailQuotaPacer;
use App\Service\Mail\GmailApiClient;
use App\Service\Mail\MessageEraser;
use App\Service\Mail\SyncOrigin;
use App\Service\OAuth\OAuthTokenManager;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* A Gmail mailbox is listed a page at a time, the Inbox first, off the queue
* new mail arrives on.
*
* The first thing a new Gmail account did was list every message id it had —
* forty requests for twenty thousand messages, in one job, before fetching
* any — and then queue all of it in front of whatever arrived next (#42).
* backfillPage() is one request and what follows from it; these pin that it
* stops after one, remembers where it was, and sends its batches to the
* import queue.
*
* GmailApiSyncerBackfillTest covers what a whole listing concludes — the
* cooldown, the attempts, the settling — through backfill(), which is these
* pages run to the end.
*/
final class GmailImportPagingTest extends TestCase
{
/** @var list<string> every URL asked for, in order */
private array $requests = [];
/** @var list<Envelope> every dispatch, with its stamps */
private array $dispatched = [];
public function testOnePageIsOneRequestAndLeavesTheTokenForTheNext(): void
{
$account = $this->account();
$step = $this->syncer([['a', 'b'], ['c']])->backfillPage($account);
self::assertSame(GmailBackfillStep::More, $step);
self::assertCount(1, $this->requests, 'the second page is another job\'s');
self::assertSame('page-1', $account->backfillPageToken);
self::assertSame(2, $account->backfillPending);
self::assertTrue($account->needsBackfill(), 'a listing in the middle of itself has concluded nothing');
}
public function testTheLastPageSettlesTheListingAndClearsTheToken(): void
{
$account = $this->account();
$syncer = $this->syncer([['a'], ['b']]);
$syncer->backfillPage($account);
$step = $syncer->backfillPage($account);
self::assertStringContainsString('pageToken=page-1', $this->requests[1]);
self::assertNull($account->backfillPageToken);
self::assertSame(0, $account->backfillPending);
// Two messages were found unfetched, so the mailbox is not in yet: the
// next listing waits out its hour.
self::assertSame(GmailBackfillStep::Waiting, $step);
self::assertSame(1, $account->backfillAttempts);
}
public function testAListingThatFindsNothingNewFinishesTheImport(): void
{
$account = $this->account();
$step = $this->syncer([['a']], synced: ['a'])->backfillPage($account);
self::assertSame(GmailBackfillStep::Finished, $step);
self::assertFalse($account->needsBackfill());
}
/** History goes to the import queue and its worker; see messenger.yaml. */
public function testAnImportsBatchesAreSentToTheImportQueue(): void
{
$this->syncer([['a', 'b']])->backfillPage($this->account());
self::assertCount(1, $this->dispatched);
self::assertInstanceOf(SyncGmailMessageBatchMessage::class, $this->dispatched[0]->getMessage());
self::assertSame(
[GmailApiSyncer::IMPORT_QUEUE],
$this->dispatched[0]->last(TransportNamesStamp::class)?->getTransportNames(),
);
}
/**
* The contrast: the one caller that still lists a whole mailbox in a go —
* the recovery from an expired history cursor — keeps the queue it had.
*/
public function testAWholeListingInOneGoStaysOnTheLiveQueue(): void
{
$this->syncer([['a'], ['b']])->backfill($this->account());
self::assertCount(2, $this->requests);
self::assertCount(2, $this->dispatched);
self::assertNull($this->dispatched[0]->last(TransportNamesStamp::class));
}
/**
* The whole-mailbox listing is newest first with every label mixed. One
* request asks for the Inbox by name, so its newest mail is queued ahead
* of a morning's promotions.
*/
public function testTheInboxIsAskedForByNameAheadOfTheMailboxAtLarge(): void
{
$this->syncer([['inbox-1', 'inbox-2']])->importInboxFirst($this->account());
self::assertCount(1, $this->requests);
self::assertStringContainsString('labelIds=INBOX', $this->requests[0]);
self::assertStringContainsString('maxResults=200', $this->requests[0]);
self::assertSame(['inbox-1', 'inbox-2'], $this->dispatched[0]->getMessage()->gmailIds);
self::assertSame(
[GmailApiSyncer::IMPORT_QUEUE],
$this->dispatched[0]->last(TransportNamesStamp::class)?->getTransportNames(),
);
}
// ── Fixture ───────────────────────────────────────────────────────────
/**
* @param list<list<string>> $pages what each listing request answers with, in order
* @param list<string> $synced ids that are stored already
*/
private function syncer(array $pages, array $synced = []): GmailApiSyncer
{
$call = 0;
$http = new MockHttpClient(function (string $method, string $url) use ($pages, &$call): ResponseInterface {
$this->requests[] = $url;
$page = $pages[$call] ?? [];
$last = $call >= count($pages) - 1;
++$call;
return new JsonMockResponse(array_filter([
'messages' => array_map(static fn (string $id): array => ['id' => $id, 'threadId' => 't-' . $id], $page),
'nextPageToken' => true === $last ? null : sprintf('page-%d', $call),
]));
});
$tokens = $this->createStub(OAuthTokenManager::class);
$tokens->method('getValidAccessToken')->willReturn('test-token');
$messages = $this->createStub(MessageRepository::class);
$messages->method('findSyncedGmailIdsForUser')->willReturn($synced);
$bus = $this->createStub(MessageBusInterface::class);
$bus->method('dispatch')->willReturnCallback(function (object $message, array $stamps = []): Envelope {
return $this->dispatched[] = new Envelope($message, $stamps);
});
return new GmailApiSyncer(
new GmailApiClient($http, $tokens, new GmailQuotaPacer(new MockClock())),
$messages,
$this->createStub(EntityManagerInterface::class),
$bus,
new NullLogger(),
$this->createStub(MessageEraser::class),
new SyncOrigin(),
);
}
private function account(): Account
{
$account = new Account();
$account->usr = new User();
return $account;
}
}
@@ -267,6 +267,11 @@ final class GraphFolderMoveTest extends KernelTestCase
new \App\Service\Mail\SyncOrigin(),
);
// Folders that are already being followed. One without a delta link is
// the import's and would not be asked for changes at all — see
// GraphImportTest for that half.
$this->account->graphDeltaLinks = array_fill_keys(array_keys($byFolder), 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=old');
$syncer->sync($this->account, array_keys($byFolder));
$this->em->flush();
@@ -313,6 +318,11 @@ final class GraphFolderMoveTest extends KernelTestCase
new \App\Service\Mail\SyncOrigin(),
);
// Folders that are already being followed. One without a delta link is
// the import's and would not be asked for changes at all — see
// GraphImportTest for that half.
$this->account->graphDeltaLinks = array_fill_keys([$folderId], 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=old');
$syncer->sync($this->account, [$folderId]);
$this->em->flush();
+540
View File
@@ -0,0 +1,540 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Graph;
use App\Domain\Enum\Account\AuthType;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Label\Label;
use App\Entity\Label\LabelBinding;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Infrastructure\Messaging\Message\SyncGraphMessageBatchMessage;
use App\Service\Graph\GraphApiSyncer;
use App\Service\Graph\GraphImportStep;
use App\Service\Mail\GraphApiClient;
use App\Service\Mail\InitialImportState;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
/**
* A Microsoft account's history, and that it is no longer the sync's to fetch.
*
* A folder with no delta link used to be enumerated to its end by whichever
* sync met it, and every message in it sent to be fetched on the queue new
* mail arrives on (#42). What is pinned here is the separation: the sync
* plans such a folder and asks it only for what is new, and the import reads
* it a page at a time, the Inbox first, onto a queue of its own.
*
* Every request the syncer makes is recorded, because half of these tests are
* about a request that must NOT be made — and a mock that answers anything
* would let a whole-folder enumeration through without a word.
*/
final class GraphImportTest extends KernelTestCase
{
private const string INBOX = 'AAMkAD-inbox';
private const string TRASH = 'AAMkAD-trash';
private const string PROJECT = 'AAMkAD-project';
private const string DELTA_LINK = 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=done';
private const string NEXT_LINK = 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$skiptoken=page2';
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
private Account $account;
/** @var list<array{method: string, url: string, headers: list<string>}> */
private array $requests = [];
/** @var \ArrayObject<int, Envelope> */
private \ArrayObject $dispatched;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
// Seeded in the order Graph would NOT want them read in, so the order
// asserted below is the ranking's doing and not the fixture's.
$this->seedLabel('Trash', LabelRole::Trash, self::TRASH);
$this->seedLabel('Project', null, self::PROJECT);
$this->seedLabel('Inbox', LabelRole::Inbox, self::INBOX);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
// ── the sync plans, and does not enumerate ────────────────────────────
public function testAFolderMetForTheFirstTimeIsPlannedRatherThanEnumerated(): void
{
$this->syncer([])->sync($this->account, [self::TRASH, self::PROJECT, self::INBOX]);
self::assertSame([], $this->requests, 'the first sync asks no folder for its messages');
self::assertCount(0, $this->dispatched);
self::assertSame([], $this->account->graphDeltaLinks);
self::assertTrue($this->account->needsGraphImport());
}
public function testTheInboxIsReadFirstAndTheBinLast(): void
{
$this->syncer([])->sync($this->account, [self::TRASH, self::PROJECT, self::INBOX]);
self::assertSame(
[self::INBOX, self::PROJECT, self::TRASH],
array_column($this->account->graphImport, 'folder'),
);
}
public function testAFolderBeingImportedIsAskedOnlyWhatItHasReceivedSince(): void
{
$this->account->graphImport = [['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => null]];
$this->em->flush();
$this->syncer([$this->json(['value' => [['id' => 'new-1', 'parentFolderId' => self::INBOX]]])])
->sync($this->account, [self::INBOX]);
self::assertCount(1, $this->requests);
$url = urldecode($this->requests[0]['url']);
self::assertStringContainsString('/mailFolders/' . self::INBOX . '/messages?', $url);
self::assertStringNotContainsString('/delta', $url);
self::assertStringContainsString('receivedDateTime ge 2027-01-15T08:00:00Z', $url);
// And what it names goes where new mail goes: the live queue.
self::assertSame([['new-1']], $this->batches());
self::assertSame([null], $this->queues());
}
public function testAFolderWithADeltaLinkIsStillFollowedWhileAnotherImports(): void
{
$this->account->graphDeltaLinks = [self::INBOX => self::DELTA_LINK];
$this->em->flush();
$this->syncer([$this->json(['value' => [], '@odata.deltaLink' => self::DELTA_LINK . '2'])])
->sync($this->account, [self::INBOX, self::TRASH]);
self::assertCount(1, $this->requests);
self::assertSame(self::DELTA_LINK, $this->requests[0]['url']);
self::assertSame([self::INBOX => self::DELTA_LINK . '2'], $this->account->graphDeltaLinks);
self::assertSame([self::TRASH], array_column($this->account->graphImport, 'folder'));
}
public function testAnExpiredDeltaLinkHandsTheFolderBackToTheImport(): void
{
$this->account->graphDeltaLinks = [self::INBOX => self::DELTA_LINK];
$this->account->lastSyncedAt = new DateTimeImmutable('@1800000000');
$this->em->flush();
$this->syncer([new MockResponse('{"error":{"code":"SyncStateNotFound"}}', ['http_code' => 410])])
->sync($this->account, [self::INBOX]);
self::assertCount(1, $this->requests, 'the folder is not enumerated again inside the sync');
self::assertSame([], $this->account->graphDeltaLinks);
self::assertSame(
[['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => null]],
$this->account->graphImport,
);
}
// ── the import reads a page at a time ─────────────────────────────────
public function testAPageIsListedNewestFirstAndFetchedOnTheImportQueue(): void
{
$this->account->graphImport = [['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => null]];
$this->em->flush();
$step = $this->syncer([$this->json([
'value' => [['id' => 'a', 'parentFolderId' => self::INBOX], ['id' => 'b', 'parentFolderId' => self::INBOX]],
'@odata.nextLink' => self::NEXT_LINK,
])])->importPage($this->account);
self::assertSame(GraphImportStep::More, $step);
self::assertCount(1, $this->requests);
$url = urldecode($this->requests[0]['url']);
self::assertStringContainsString('/mailFolders/' . self::INBOX . '/messages/delta?', $url);
self::assertStringContainsString('$orderby=receivedDateTime desc', $url);
self::assertContains(
sprintf('Prefer: IdType="ImmutableId", odata.maxpagesize=%d', GraphApiSyncer::IMPORT_PAGE_SIZE),
$this->requests[0]['headers'],
);
self::assertSame([['a', 'b']], $this->batches());
self::assertSame([GraphApiSyncer::IMPORT_QUEUE], $this->queues());
self::assertSame(self::NEXT_LINK, $this->account->graphImport[0]['next']);
self::assertSame([], $this->account->graphDeltaLinks, 'a folder is not followed until it has been read to its end');
}
public function testTheNextPageContinuesFromTheStoredLink(): void
{
$this->account->graphImport = [['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => self::NEXT_LINK]];
$this->em->flush();
$this->syncer([$this->json(['value' => [], '@odata.nextLink' => self::NEXT_LINK . '3'])])
->importPage($this->account);
self::assertSame(self::NEXT_LINK, $this->requests[0]['url']);
self::assertSame(self::NEXT_LINK . '3', $this->account->graphImport[0]['next']);
}
public function testTheLastPageMakesTheFolderTheSyncsAndMovesOnToTheNext(): void
{
$this->account->graphImport = [
['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => self::NEXT_LINK],
['folder' => self::TRASH, 'since' => 1_800_000_000, 'next' => null],
];
$this->em->flush();
$step = $this->syncer([$this->json(['value' => [], '@odata.deltaLink' => self::DELTA_LINK])])
->importPage($this->account);
self::assertSame(GraphImportStep::More, $step);
self::assertSame([self::INBOX => self::DELTA_LINK], $this->account->graphDeltaLinks);
self::assertSame([self::TRASH], array_column($this->account->graphImport, 'folder'));
}
public function testTheLastPageOfTheLastFolderFinishesTheImport(): void
{
$this->account->graphImport = [['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => self::NEXT_LINK]];
$this->em->flush();
$step = $this->syncer([$this->json(['value' => [], '@odata.deltaLink' => self::DELTA_LINK])])
->importPage($this->account);
self::assertSame(GraphImportStep::Finished, $step);
self::assertFalse($this->account->needsGraphImport());
self::assertTrue(self::getContainer()->get(InitialImportState::class)->isComplete($this->account));
}
public function testAnAccountWithFoldersStillToReadIsNotCalledImported(): void
{
$this->account->graphDeltaLinks = [self::INBOX => self::DELTA_LINK];
$this->account->graphImport = [['folder' => self::TRASH, 'since' => 1_800_000_000, 'next' => null]];
$this->em->flush();
self::assertFalse(self::getContainer()->get(InitialImportState::class)->isComplete($this->account));
}
public function testAMessageAlreadyStoredIsNotFetchedASecondTime(): void
{
$this->seedMessage('have-it');
$this->account->graphImport = [['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => null]];
$this->em->flush();
$this->syncer([$this->json([
'value' => [['id' => 'have-it', 'parentFolderId' => self::INBOX], ['id' => 'want-it', 'parentFolderId' => self::INBOX]],
'@odata.deltaLink' => self::DELTA_LINK,
])])->importPage($this->account);
self::assertSame([['want-it']], $this->batches());
}
public function testAFolderThatCannotBeReadGoesToTheBackRatherThanHoldingTheOthers(): void
{
$this->account->graphImport = [
['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => self::NEXT_LINK],
['folder' => self::TRASH, 'since' => 1_800_000_000, 'next' => null],
];
$this->em->flush();
$step = $this->syncer([new MockResponse('{"error":{"code":"ErrorInternalServerError"}}', ['http_code' => 500])])
->importPage($this->account);
self::assertSame(GraphImportStep::Waiting, $step);
self::assertSame([self::TRASH, self::INBOX], array_column($this->account->graphImport, 'folder'));
self::assertSame(self::NEXT_LINK, $this->account->graphImport[1]['next'], 'and keeps its place for when it can');
}
public function testAnEnumerationTheServerHasForgottenStartsOver(): void
{
$this->account->graphImport = [['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => self::NEXT_LINK]];
$this->em->flush();
$step = $this->syncer([new MockResponse('{"error":{"code":"SyncStateNotFound"}}', ['http_code' => 410])])
->importPage($this->account);
self::assertSame(GraphImportStep::More, $step);
self::assertSame(
[['folder' => self::INBOX, 'since' => 1_800_000_000, 'next' => null]],
$this->account->graphImport,
);
}
// ── removals that cannot be judged yet ────────────────────────────────
/**
* Removed from the Inbox while the bin is still being read: it may be in
* the bin. Erasing it now would delete a message that was only moved, and
* forgetting it would leave the row of one that was deleted for good.
*/
public function testARemovalIsKeptRatherThanActedOnWhileAFolderIsStillImporting(): void
{
$message = $this->seedMessage('moved-or-deleted');
$id = $message->id;
$this->account->graphDeltaLinks = [self::INBOX => self::DELTA_LINK];
$this->account->graphImport = [['folder' => self::TRASH, 'since' => time() + 3600, 'next' => null]];
$this->em->flush();
$this->syncer([
$this->json([
'value' => [['id' => 'moved-or-deleted', '@removed' => ['reason' => 'deleted']]],
'@odata.deltaLink' => self::DELTA_LINK,
]),
$this->json(['value' => []]),
])->sync($this->account, [self::INBOX, self::TRASH]);
self::assertNotNull($this->em->find(Message::class, $id), 'not erased on half the evidence');
self::assertSame(['moved-or-deleted'], $this->account->graphRemovals);
}
public function testAKeptRemovalIsActedOnOnceEveryFolderHasAnswered(): void
{
$message = $this->seedMessage('deleted', labelled: false);
$id = $message->id;
$this->account->graphDeltaLinks = [self::INBOX => self::DELTA_LINK];
$this->account->graphRemovals = ['deleted'];
$this->em->flush();
$this->syncer([$this->json(['value' => [], '@odata.deltaLink' => self::DELTA_LINK])])
->sync($this->account, [self::INBOX]);
$this->em->clear();
self::assertNull($this->em->find(Message::class, $id));
self::assertSame([], $this->em->find(Account::class, $this->account->id)->graphRemovals);
}
public function testAKeptRemovalThatTurnedUpInAFolderIsLeftAlone(): void
{
$message = $this->seedMessage('only-moved');
$id = $message->id;
$this->account->graphDeltaLinks = [self::INBOX => self::DELTA_LINK];
$this->account->graphRemovals = ['only-moved'];
$this->em->flush();
$this->syncer([$this->json(['value' => [], '@odata.deltaLink' => self::DELTA_LINK])])
->sync($this->account, [self::INBOX]);
$this->em->clear();
self::assertNotNull($this->em->find(Message::class, $id));
}
// ── helpers ───────────────────────────────────────────────────────────
/**
* A syncer over a client that answers with the given responses in order
* and records what it was asked, and a bus that records instead of
* sending. One response too few fails the test by itself, which is the
* point: a request nobody expected is the regression.
*
* @param list<MockResponse> $responses
*/
private function syncer(array $responses): GraphApiSyncer
{
$this->requests = [];
$this->dispatched = new \ArrayObject();
$http = new MockHttpClient(function (string $method, string $url, array $options) use (&$responses): MockResponse {
$this->requests[] = ['method' => $method, 'url' => $url, 'headers' => $options['headers'] ?? []];
return array_shift($responses) ?? throw new \LogicException('An unexpected request: ' . $url);
});
$bus = new class($this->dispatched) implements MessageBusInterface {
/** @param \ArrayObject<int, Envelope> $dispatched */
public function __construct(private \ArrayObject $dispatched)
{
}
public function dispatch(object $message, array $stamps = []): Envelope
{
$envelope = Envelope::wrap($message, $stamps);
$this->dispatched->append($envelope);
return $envelope;
}
};
$container = self::getContainer();
return new GraphApiSyncer(
new GraphApiClient($http, $container->get('App\Service\OAuth\OAuthTokenManager')),
$container->get('App\Service\Graph\GraphFolderResolver'),
$container->get('App\Service\Graph\GraphLabelPolicy'),
$container->get('App\Repository\Mail\MessageRepository'),
$this->em,
$container->get('App\Jmap\State\StateManager'),
$bus,
new \Psr\Log\NullLogger(),
$container->get('App\Service\Mail\MessageEraser'),
$container->get('App\Service\Mail\ThreadStatusUpdater'),
new \App\Service\Mail\SyncOrigin(),
);
}
/** @param array<string, mixed> $body */
private function json(array $body): MockResponse
{
return new MockResponse(json_encode($body, JSON_THROW_ON_ERROR), [
'response_headers' => ['content-type' => 'application/json'],
]);
}
/** @return list<list<string>> the ids of each batch sent to be fetched */
private function batches(): array
{
return array_map(
static function (Envelope $envelope): array {
$message = $envelope->getMessage();
self::assertInstanceOf(SyncGraphMessageBatchMessage::class, $message);
return $message->graphIds;
},
$this->dispatched->getArrayCopy(),
);
}
/** @return list<string|null> the queue each batch was sent to by name, null for the routing table's */
private function queues(): array
{
return array_map(
static fn (Envelope $envelope): ?string => $envelope->last(TransportNamesStamp::class)?->getTransportNames()[0],
$this->dispatched->getArrayCopy(),
);
}
private function seedMessage(string $graphId, bool $labelled = true): Message
{
$thread = new MessageThread();
$thread->account = $this->account;
$thread->subject = 'Stored already';
$thread->normalizedSubject = 'stored already';
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = new DateTimeImmutable('-1 hour');
$message = new Message();
$message->account = $this->account;
$message->thread = $thread;
$message->subject = 'Stored already';
$message->fromAddress = 'sender@example.test';
$message->graphId = $graphId;
$message->receivedAt = new DateTimeImmutable('-1 hour');
$message->sentAt = $message->receivedAt;
$message->hasAttachments = false;
$message->flags = [];
$message->syncedAt = new DateTimeImmutable();
if (true === $labelled) {
$message->addLabel(
self::getContainer()->get('App\Service\Graph\GraphFolderResolver')->resolveFolder(self::INBOX, $this->account),
);
}
$thread->addMessage($message);
$this->em->persist($thread);
$this->em->persist($message);
$this->em->flush();
return $message;
}
private function seedLabel(string $name, ?LabelRole $role, string $graphFolderId): Label
{
$label = new Label();
$label->usr = $this->user;
$label->name = $name;
$label->role = $role;
$label->isVisible = true;
$this->em->persist($label);
$this->em->flush();
$binding = new LabelBinding();
$binding->label = $label;
$binding->account = $this->account;
$binding->graphFolderId = $graphFolderId;
$label->addBinding($binding);
$this->em->persist($binding);
$this->em->flush();
return $label;
}
private function seedAccount(): Account
{
$account = new Account();
$account->usr = $this->user;
$account->name = 'Graph fixture';
$account->email = 'graph@example.test';
$account->username = uniqid('graph-', true);
$account->imapHost = 'outlook.office365.com';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->authType = AuthType::OAuth2->value;
$account->oauthProvider = 'microsoft';
$account->oauthAccessToken = 'test-access-token';
$account->oauthTokenExpiry = new DateTimeImmutable('+1 day');
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
return $account;
}
private function seedUser(): User
{
$user = new User();
$user->email = 'graph-import-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Graph';
$user->nameLast = 'Import';
$user->roles = ['ROLE_USER'];
$user->password = 'x';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
@@ -220,6 +220,11 @@ final class GraphInboundReadStateTest extends KernelTestCase
new \App\Service\Mail\SyncOrigin(),
);
// Folders that are already being followed. One without a delta link is
// the import's and would not be asked for changes at all — see
// GraphImportTest for that half.
$this->account->graphDeltaLinks = array_fill_keys([self::INBOX_FOLDER], 'https://graph.microsoft.com/v1.0/me/mailFolders/x/messages/delta?$deltatoken=old');
$syncer->sync($this->account, [self::INBOX_FOLDER]);
$this->em->flush();
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Imap;
use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\Folder;
use Webklex\PHPIMAP\Message;
use Webklex\PHPIMAP\Query\WhereQuery;
/**
* A server with one folder in it, whose contents are a script.
*
* For ImapMailboxImporter, which asks a folder two things — which UIDs it
* holds below a floor, and for a named set of them — and for nothing else.
* Both go through Folder::messages(), so that is the one seam: the folder here
* hands out a FakeQuery over the script, and everything above it is the real
* importer, the real paged fetch and the real MessageSyncer.
*
* The script can be changed between calls (`$script` is public), which is how
* a test says "and now the message that would not parse parses".
*/
final class FakeImportClient extends Client
{
/** @var array<int, Message|\Throwable> UID => the message, or what building it throws */
public array $script = [];
/** @var list<list<int>> the UIDs asked for by name, one entry per page fetched */
public array $fetched = [];
/**
* @param array<int, Message|\Throwable> $script
*/
public static function holding(array $script): self
{
$client = new \ReflectionClass(self::class)->newInstanceWithoutConstructor();
$client->script = $script;
return $client;
}
public function getFolderByPath($folder_path, bool $utf7 = false, bool $soft_fail = false): Folder
{
return $this->folder();
}
public function getFolderByName($folder_name, bool $soft_fail = false): Folder
{
return $this->folder();
}
public function disconnect(): Client
{
return $this;
}
private function folder(): Folder
{
$client = $this;
return new class($client) extends Folder {
public function __construct(private readonly FakeImportClient $server)
{
}
public function messages(array $extensions = []): WhereQuery
{
return $this->server->query();
}
};
}
public function query(): WhereQuery
{
return FakeQuery::finding($this->script, function (array $uids): void {
$this->fetched[] = $uids;
});
}
}
+53 -6
View File
@@ -6,7 +6,7 @@ namespace App\Tests\Service\Imap;
use Webklex\PHPIMAP\Exceptions\GetMessagesFailedException;
use Webklex\PHPIMAP\Message;
use Webklex\PHPIMAP\Query\Query;
use Webklex\PHPIMAP\Query\WhereQuery;
use Webklex\PHPIMAP\Support\MessageCollection;
use Illuminate\Support\Collection;
@@ -20,10 +20,16 @@ use Illuminate\Support\Collection;
* by soft-fail alone. Everything that would need a server — the search, the
* fetch — is replaced by the script.
*
* It understands the one kind of criterion this application's syncers write —
* `CUSTOM UID …`, a range or a set (see MessageSyncer::uidRangeCriteria()) —
* and answers the search with the scripted UIDs that fall inside it, so a
* caller that asks for "everything below 120" and then for "117, 118 and 119"
* is answered as a server would answer.
*
* Made with newInstanceWithoutConstructor(), because the real constructor asks
* a connected client for its configuration.
*/
final class FakeQuery extends Query
final class FakeQuery extends WhereQuery
{
/** @var array<int, Message|\Throwable> UID => the message, or what building it throws */
private array $script = [];
@@ -31,20 +37,57 @@ final class FakeQuery extends Query
/** @var list<int> the page number of every fetch, in order */
public array $fetchedPages = [];
/** @var list<int>|null the UIDs the criterion names, or null for all */
private ?array $wanted = null;
/** @var array{0: int, 1: int}|null the UID range the criterion names */
private ?array $range = null;
/** @var (\Closure(list<int>): void)|null told the UIDs of every page fetched */
private ?\Closure $onFetch = null;
/**
* @param array<int, Message|\Throwable> $script
* @param array<int, Message|\Throwable> $script
* @param (\Closure(list<int>): void)|null $onFetch
*/
public static function finding(array $script): self
public static function finding(array $script, ?\Closure $onFetch = null): self
{
$query = new \ReflectionClass(self::class)->newInstanceWithoutConstructor();
$query->script = $script;
$query->script = $script;
$query->onFetch = $onFetch;
return $query;
}
public function where(mixed $criteria, mixed $value = null): static
{
$criteria = (string) $criteria;
if (1 === preg_match('~^CUSTOM UID (\d+):(\d+|\*)$~', $criteria, $m)) {
$this->range = [(int) $m[1], '*' === $m[2] ? PHP_INT_MAX : (int) $m[2]];
} elseif (1 === preg_match('~^CUSTOM UID ([\d,]+)$~', $criteria, $m)) {
$this->wanted = array_map('intval', explode(',', $m[1]));
} else {
throw new \LogicException(sprintf('FakeQuery does not understand the criterion "%s".', $criteria));
}
return $this;
}
public function search(): Collection
{
return new Collection(array_keys($this->script));
$uids = array_keys($this->script);
if (null !== $this->range) {
[$from, $to] = $this->range;
$uids = array_values(array_filter($uids, static fn (int $uid): bool => $uid >= $from && $uid <= $to));
}
if (null !== $this->wanted) {
$uids = array_values(array_intersect($uids, $this->wanted));
}
return new Collection($uids);
}
public function curate_messages(Collection $available_messages): MessageCollection
@@ -53,6 +96,10 @@ final class FakeQuery extends Query
$messages = MessageCollection::make();
if (null !== $this->onFetch) {
($this->onFetch)(array_values($available_messages->forPage($this->page, $this->limit)->all()));
}
foreach ($available_messages->forPage($this->page, $this->limit) as $uid) {
$entry = $this->script[$uid];
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Imap;
use App\Entity\Mail\Mailbox;
use App\Service\Imap\ImapImportPlan;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\Connection\Protocols\ProtocolInterface;
use Webklex\PHPIMAP\Connection\Protocols\Response;
/**
* The first time a folder is met, the mark for new mail goes to its top and
* its history is left for the importer.
*
* That one move is what makes a new account show today's mail first. Without
* it the mark starts at nothing and the first sync asks for `1:*` — the whole
* folder, oldest first, in the job that was only meant to look (#42).
*/
final class ImapImportPlanTest extends TestCase
{
public function testAFolderIsPlannedFromItsTopWithEverythingBelowLeftAsHistory(): void
{
$mailbox = $this->mailbox();
self::assertTrue($this->plan()->begin($mailbox, $this->serverSaying(['uidnext' => 4712])));
self::assertSame(4711, $mailbox->lastSeenUid, 'new mail is whatever comes after the newest message there is');
self::assertSame(4712, $mailbox->importFloorUid, 'and all of what is there is history');
self::assertTrue($mailbox->isImporting());
}
/** No history is no import, and saying so keeps the folder off the importer's list. */
public function testAnEmptyFolderHasNothingToImport(): void
{
$mailbox = $this->mailbox();
$this->plan()->begin($mailbox, $this->serverSaying(['uidnext' => 1]));
self::assertSame(0, $mailbox->importFloorUid);
self::assertFalse($mailbox->isImporting());
}
/**
* A folder the old oldest-first pass left half read has a mark in its
* middle. It goes up to the top, never down, and the gap is history.
*/
public function testAMarkLeftInTheMiddleOfAFolderOnlyEverMovesUp(): void
{
$mailbox = $this->mailbox();
$mailbox->lastSeenUid = 900;
$this->plan()->begin($mailbox, $this->serverSaying(['uidnext' => 4712]));
self::assertSame(4711, $mailbox->lastSeenUid);
$ahead = $this->mailbox();
$ahead->lastSeenUid = 9000;
$this->plan()->begin($ahead, $this->serverSaying(['uidnext' => 4712]));
self::assertSame(9000, $ahead->lastSeenUid);
}
/**
* A server that will not say where its top is has not given enough to
* plan with. The folder stays unplanned and is read the old way, which is
* slow and correct — the alternative is guessing which mail is new.
*/
public function testAServerThatDoesNotNameItsNextUidLeavesTheFolderUnplanned(): void
{
$mailbox = $this->mailbox();
self::assertFalse($this->plan()->begin($mailbox, $this->serverSaying(['uidvalidity' => 7])));
self::assertNull($mailbox->importFloorUid);
self::assertNull($mailbox->lastSeenUid);
}
private function plan(): ImapImportPlan
{
return new ImapImportPlan($this->createStub(EntityManagerInterface::class), new NullLogger());
}
private function mailbox(): Mailbox
{
$mailbox = new Mailbox();
$mailbox->name = 'INBOX';
$mailbox->fullPath = 'INBOX';
return $mailbox;
}
/**
* @param array<string, int> $examined what the server answers an EXAMINE with
*
* A subclass rather than a stub of Client: the library's client disconnects
* in its destructor, and a stub answers that call with another stub of
* itself, which is then destroyed in turn — without end.
*/
private function serverSaying(array $examined): Client
{
$response = $this->createStub(Response::class);
$response->method('validatedData')->willReturn($examined);
$connection = $this->createStub(ProtocolInterface::class);
$connection->method('examineFolder')->willReturn($response);
$client = new \ReflectionClass(ExaminedClient::class)->newInstanceWithoutConstructor();
$client->server = $connection;
return $client;
}
}
/** A client that is connected to whatever it is handed and never hangs up. */
final class ExaminedClient extends Client
{
public ProtocolInterface $server;
public function getConnection(): ProtocolInterface
{
return $this->server;
}
public function disconnect(): Client
{
return $this;
}
}
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Imap;
use App\Domain\Helper\ImapConnectionFactory;
use App\Entity\Mail\Mailbox;
use App\Service\Imap\ImapImportOutcome;
use App\Service\Imap\ImapMailboxImporter;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Webklex\PHPIMAP\Message as ImapMessage;
/**
* A folder's history arrives newest first, a page at a time, and no single
* message can stop it.
*
* A first sync used to read a folder from its oldest message to its newest in
* one job. The mail somebody was waiting for came last, the folder after it
* waited for all of it, and so did every other account (#42). The importer is
* what brings the history in now: this is what one call to it does to a
* folder, checked in the database it writes to.
*
* The server is a script (FakeImportClient). Everything else is real — the
* importer, the paged fetch, MessageSyncer and the pipeline behind it — since
* the claims are about what ends up stored and where the floor stands, and
* those are the things a double would be asserting into existence.
*
* PAGE_SIZE is fifty; the folders here hold a hundred and thirty, which is two
* full pages and a part of one.
*/
final class ImapMailboxImporterTest extends KernelTestCase
{
use SeedsMarkerFixtures;
private const int TOP = 130;
private Connection $connection;
private ImapMailboxImporter $importer;
private int $mailboxId;
protected function setUp(): void
{
self::bootKernel();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->connection = static::getContainer()->get(Connection::class);
$this->importer = static::getContainer()->get(ImapMailboxImporter::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
// As ImapImportPlan leaves a folder whose highest UID is TOP: new mail
// is everything above it, and the history starts just above too.
$mailbox = new Mailbox();
$mailbox->account = $this->account;
$mailbox->name = 'INBOX';
$mailbox->fullPath = 'INBOX';
$mailbox->isSyncEnabled = true;
$mailbox->lastSeenUid = self::TOP;
$mailbox->importFloorUid = self::TOP + 1;
$this->em->persist($mailbox);
$this->em->flush();
$this->mailboxId = (int) $mailbox->id;
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testTheFirstPageIsTheNewestFiftyRatherThanTheOldest(): void
{
$server = FakeImportClient::holding($this->mails(range(1, self::TOP)));
$outcome = $this->importer->importPage($this->mailboxId, $server);
self::assertSame(ImapImportOutcome::More, $outcome);
self::assertSame(range(81, 130), $this->storedUids());
$mailbox = $this->row();
self::assertSame(81, (int) $mailbox['import_floor_uid'], 'the floor stands at the lowest UID brought in');
self::assertSame(130, (int) $mailbox['import_total']);
self::assertSame(80, (int) $mailbox['import_remaining']);
self::assertSame(self::TOP, (int) $mailbox['last_seen_uid'], 'the mark for new mail is not the importer\'s to move');
}
public function testPageAfterPageReachesTheBottomAndThenSaysSo(): void
{
$server = FakeImportClient::holding($this->mails(range(1, self::TOP)));
self::assertSame(ImapImportOutcome::More, $this->importer->importPage($this->mailboxId, $server));
self::assertSame(ImapImportOutcome::More, $this->importer->importPage($this->mailboxId, $server));
self::assertSame(ImapImportOutcome::Finished, $this->importer->importPage($this->mailboxId, $server));
self::assertSame(range(1, 130), $this->storedUids());
self::assertSame(0, (int) $this->row()['import_floor_uid'], 'zero is "nothing left below"');
self::assertSame(0, (int) $this->row()['import_remaining']);
// And a finished folder is left alone: nothing more is asked of the server.
$asked = count($server->fetched);
self::assertSame(ImapImportOutcome::Finished, $this->importer->importPage($this->mailboxId, $server));
self::assertCount($asked, $server->fetched);
}
/**
* UIDs have holes. A page is fifty MESSAGES, found by asking what exists,
* and not a span of fifty UIDs — which in a folder somebody has tidied is
* a handful of messages, or none.
*/
public function testAPageIsFiftyMessagesHoweverTheirUidsAreSpread(): void
{
// Every third UID only.
$server = FakeImportClient::holding($this->mails(range(1, self::TOP, 3)));
$this->importer->importPage($this->mailboxId, $server);
self::assertCount(44, $this->storedUids(), 'all forty-four of them fit one page');
self::assertSame(0, (int) $this->row()['import_floor_uid']);
}
/**
* What is already here is not downloaded again to find that out. Gmail
* over IMAP shows every message in All Mail as well as in its folder, and
* a folder the old oldest-first pass had half read is planned again from
* the top; either way the stored messages are simply not asked for.
*/
public function testMessagesAlreadyStoredAreNeverFetchedAgain(): void
{
$server = FakeImportClient::holding($this->mails(range(1, self::TOP)));
// Fifty of them are here already: 11 to 60, one page of a folder that
// held sixty at the time.
$this->importer->importPage($this->mailboxId, FakeImportClient::holding($this->mails(range(1, 60))));
self::assertSame(range(11, 60), $this->storedUids());
// Planned again from the top, as an upgrade or a rebuilt folder is.
$this->connection->executeStatement(
'UPDATE mailbox SET import_floor_uid = ?, import_total = NULL, import_remaining = NULL WHERE id = ?',
[self::TOP + 1, $this->mailboxId],
);
$this->em->clear();
$this->importer->importPage($this->mailboxId, $server);
$this->importer->importPage($this->mailboxId, $server);
$asked = array_merge(...$server->fetched);
self::assertSame([], array_values(array_intersect($asked, range(11, 60))), 'none of the stored fifty was asked for');
self::assertSame(80, (int) $this->row()['import_total'], 'the total is what was missing, not what the folder holds');
}
/**
* A message that will not store holds the floor where it is, and the rest
* of its page is stored all the same. The next call is that message again
* with whatever is next below it.
*/
public function testAMessageThatWillNotStoreCostsItselfAndIsAskedForAgain(): void
{
$script = $this->mails(range(1, self::TOP));
$script[100] = new \RuntimeException('no content found');
$server = FakeImportClient::holding($script);
self::assertSame(ImapImportOutcome::Retry, $this->importer->importPage($this->mailboxId, $server));
self::assertSame(array_values(array_diff(range(81, 130), [100])), $this->storedUids());
self::assertSame(self::TOP + 1, (int) $this->row()['import_floor_uid'], 'the floor has not moved past it');
self::assertSame(1, (int) $this->row()['import_page_attempts']);
// It parses now — a fix shipped, say. The same page is asked for
// again: the one that failed, and the forty-nine below what is stored.
$server->script[100] = $this->mails([100])[100];
self::assertSame(ImapImportOutcome::More, $this->importer->importPage($this->mailboxId, $server));
self::assertContains(100, $this->storedUids());
self::assertSame(0, (int) $this->row()['import_page_attempts'], 'a page that goes through earns the count back');
}
/**
* Held, not held for ever. After MAX_PAGE_ATTEMPTS the floor moves past
* the message, and the folder's history goes on without it.
*/
public function testAMessageThatNeverStoresIsPassedOverAfterFiveTries(): void
{
$script = $this->mails(range(1, self::TOP));
$script[100] = new \RuntimeException('no content found');
$server = FakeImportClient::holding($script);
for ($try = 1; $try < ImapMailboxImporter::MAX_PAGE_ATTEMPTS; ++$try) {
self::assertSame(ImapImportOutcome::Retry, $this->importer->importPage($this->mailboxId, $server), sprintf('try %d', $try));
}
self::assertNotSame(ImapImportOutcome::Retry, $this->importer->importPage($this->mailboxId, $server));
self::assertLessThan(100, (int) $this->row()['import_floor_uid'], 'the floor is below it now');
// And it is not asked for again: the folder finishes without it.
while (ImapImportOutcome::Finished !== $this->importer->importPage($this->mailboxId, $server)) {
continue;
}
self::assertSame(array_values(array_diff(range(1, 130), [100])), $this->storedUids());
}
/**
* The counter new mail uses is not touched from down here. The two ends
* of a folder are read by different workers, and on one ledger a message
* failing at each end would take the count from the other for good.
*/
public function testTheImportKeepsItsOwnCountAndLeavesTheOneForNewMailAlone(): void
{
$this->connection->executeStatement(
'UPDATE mailbox SET failed_uid = 131, failed_uid_attempts = 3 WHERE id = ?',
[$this->mailboxId],
);
$this->em->clear();
$script = $this->mails(range(1, self::TOP));
$script[100] = new \RuntimeException('no content found');
$this->importer->importPage($this->mailboxId, FakeImportClient::holding($script));
self::assertSame(131, (int) $this->row()['failed_uid']);
self::assertSame(3, (int) $this->row()['failed_uid_attempts']);
}
public function testAnEmptyFolderIsFinishedAtOnce(): void
{
self::assertSame(ImapImportOutcome::Finished, $this->importer->importPage($this->mailboxId, FakeImportClient::holding([])));
self::assertSame(0, (int) $this->row()['import_floor_uid']);
}
// ── Fixtures ──────────────────────────────────────────────────────────
/** @return list<int> */
private function storedUids(): array
{
return array_map('intval', $this->connection->fetchFirstColumn(
'SELECT imap_uid FROM message WHERE mailbox_id = ? ORDER BY imap_uid',
[$this->mailboxId],
));
}
/** @return array<string, mixed> */
private function row(): array
{
$this->em->clear();
return (array) $this->connection->fetchAssociative(
'SELECT last_seen_uid, import_floor_uid, import_total, import_remaining, import_page_attempts, failed_uid, failed_uid_attempts FROM mailbox WHERE id = ?',
[$this->mailboxId],
);
}
/**
* @param list<int> $uids
*
* @return array<int, ImapMessage>
*/
private function mails(array $uids): array
{
$mails = [];
foreach ($uids as $uid) {
$raw = "From: Sender <sender@example.test>\r\n"
. "To: {$this->account->email}\r\n"
. "Subject: History {$uid}\r\n"
. "Date: Mon, 21 Sep 2026 10:00:00 +0200\r\n"
. "Message-ID: <history-{$uid}@example.test>\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n\r\nHello.\r\n";
$mails[$uid] = ImapMessage::fromString($raw, ImapConnectionFactory::config())->setUid($uid);
}
return $mails;
}
}
@@ -451,10 +451,11 @@ final class ClassificationHoldFlowTest extends KernelTestCase
$this->em->flush();
}
/** What ends an IMAP import: every folder has completed a sync. */
/** What ends an IMAP import: no folder has history left to bring in. */
private function importFinished(): void
{
$this->mailbox->syncedAt = new DateTimeImmutable('-1 day');
$this->mailbox->syncedAt = new DateTimeImmutable('-1 day');
$this->mailbox->importFloorUid = 0;
$this->em->flush();
}
+205
View File
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Account\AuthType;
use App\Domain\Enum\Mail\MailboxSpecialUse;
use App\Entity\Label\Label;
use App\Entity\Label\LabelBinding;
use App\Entity\Mail\Mailbox;
use App\Service\Mail\ImportProgress;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Twig\Environment;
/**
* An import says how far it has got, and stops saying anything when it is over.
*
* It used to say nothing at all: mail appeared in bursts, and nothing told a
* working import from a stalled one (#42). The line in the topbar's indicator
* is read off the folders' own import markers, so the figures here are the
* ones the import itself moves.
*/
final class ImportProgressTest extends KernelTestCase
{
use SeedsMarkerFixtures;
private Connection $connection;
private ImportProgress $progress;
protected function setUp(): void
{
self::bootKernel();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->connection = static::getContainer()->get(Connection::class);
$this->progress = static::getContainer()->get(ImportProgress::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testAnImportingAccountShowsWhatIsInAgainstWhatItSetOutToFetch(): void
{
// The Inbox is part-way; Sent is finished and still counts towards
// both figures; the bin has not been looked at and adds nothing yet.
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 812, total: 1000, remaining: 750);
$this->folder('Sent', MailboxSpecialUse::SENT, floor: 0, total: 200, remaining: 0);
$this->folder('Trash', MailboxSpecialUse::TRASH, floor: null, total: null, remaining: null);
$this->account->importBeatAt = new DateTimeImmutable();
$this->em->flush();
$lines = $this->progress->forUser($this->user);
self::assertCount(1, $lines);
self::assertSame($this->account->id, $lines[0]->account->id);
self::assertSame(450, $lines[0]->done);
self::assertSame(1200, $lines[0]->total);
self::assertSame(37, $lines[0]->percent(), 'floored, never rounded up');
self::assertSame('INBOX', $lines[0]->folder?->fullPath, 'the folder being read is the first by rank');
self::assertFalse($lines[0]->waiting);
}
/**
* Microsoft keeps its folders as labels, so the line names a label where
* an IMAP line names a mailbox — and measures what is stored against what
* the folder list said the mailbox holds.
*/
public function testAMicrosoftImportNamesTheFolderItIsOnByItsLabel(): void
{
$label = new Label();
$label->usr = $this->user;
$label->name = 'Projects';
$label->isVisible = true;
$binding = new LabelBinding();
$binding->label = $label;
$binding->account = $this->account;
$binding->graphFolderId = 'AAMkAD-projects';
$label->addBinding($binding);
$this->em->persist($label);
$this->em->persist($binding);
$this->account->authType = AuthType::OAuth2->value;
$this->account->oauthProvider = 'microsoft';
$this->account->importTotal = 4000;
$this->account->importBeatAt = new DateTimeImmutable();
$this->account->graphImport = [
['folder' => 'AAMkAD-projects', 'since' => 1_800_000_000, 'next' => null],
['folder' => 'AAMkAD-trash', 'since' => 1_800_000_000, 'next' => null],
];
$this->em->flush();
$lines = $this->progress->forUser($this->user);
self::assertCount(1, $lines);
self::assertSame(0, $lines[0]->done);
self::assertSame(4000, $lines[0]->total);
self::assertSame('Projects', $lines[0]->label?->name);
self::assertNull($lines[0]->folder);
$this->account->graphImport = [];
$this->em->flush();
self::assertSame([], $this->progress->forUser($this->user), 'and nothing once every folder is in');
}
public function testAnAccountWhoseHistoryIsInShowsNothing(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 0, total: 1000, remaining: 0);
self::assertSame([], $this->progress->forUser($this->user));
}
/**
* A bar that has stopped says nothing by itself. One that nothing has
* touched for a while says "waiting", which at least is known to be true.
*/
public function testAnImportNothingHasTouchedForAWhileSaysItIsWaiting(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 812, total: 1000, remaining: 750);
$this->account->importBeatAt = new DateTimeImmutable('-1 hour');
$this->em->flush();
self::assertTrue($this->progress->forUser($this->user)[0]->waiting);
}
/** Somebody else's import is not this person's to watch. */
public function testALineIsShownOnlyToTheOwnerOfTheAccount(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 812, total: 1000, remaining: 750);
self::assertSame([], $this->progress->forUser($this->seedUser()));
}
/**
* And it reaches the screen: the indicator renders the line, the account
* and the figures, with the folder it is on.
*/
public function testTheIndicatorRendersTheLine(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 812, total: 21000, remaining: 19760);
$this->account->importBeatAt = new DateTimeImmutable();
$this->em->flush();
$html = static::getContainer()->get(Environment::class)->render('mail/_jobs_indicator.html.twig', [
'jobs' => [],
'imports' => $this->progress->forUser($this->user),
]);
self::assertStringContainsString('data-import-line', $html);
self::assertStringContainsString($this->account->displayAddress, $html);
self::assertStringContainsString('1,240 / 21,000', $html);
self::assertStringContainsString('INBOX', $html);
}
/** Nothing running is no button at all, as it was before imports were shown. */
public function testWithNothingRunningTheIndicatorIsEmpty(): void
{
$html = static::getContainer()->get(Environment::class)->render('mail/_jobs_indicator.html.twig', [
'jobs' => [],
'imports' => [],
]);
self::assertStringNotContainsString('<button', $html);
}
private function folder(string $path, ?MailboxSpecialUse $use, ?int $floor, ?int $total, ?int $remaining): Mailbox
{
$mailbox = new Mailbox();
$mailbox->account = $this->account;
$mailbox->name = $path;
$mailbox->fullPath = $path;
$mailbox->specialUse = $use;
$mailbox->isSyncEnabled = true;
$mailbox->importFloorUid = $floor;
$mailbox->importTotal = $total;
$mailbox->importRemaining = $remaining;
$this->em->persist($mailbox);
$this->em->flush();
return $mailbox;
}
}
+262
View File
@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Account\AuthType;
use App\Domain\Enum\Mail\MailboxSpecialUse;
use App\Entity\Mail\Mailbox;
use App\Infrastructure\Messaging\Message\ImportMailMessage;
use App\Infrastructure\Messaging\Message\SyncAccountMessage;
use App\Repository\Mail\MailboxRepository;
use App\Service\Mail\InitialImportState;
use App\Service\Mail\MailImporter;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
/**
* An account's history is started once, on the import queue, in the order a
* person would want it — and is not started again while it is moving.
*
* The import is a chain of short jobs, each asking for the next (see
* MailImporter). What is pinned here is everything around a page rather than
* the page itself, which ImapMailboxImporterTest covers: what starts the
* chain, what keeps a second one from starting beside it, which folder is
* first, and what "still importing" means now that a folder's first sync ends
* at once.
*/
final class MailImporterTest extends KernelTestCase
{
use SeedsMarkerFixtures;
private Connection $connection;
private MailImporter $importer;
protected function setUp(): void
{
self::bootKernel();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->connection = static::getContainer()->get(Connection::class);
$this->importer = static::getContainer()->get(MailImporter::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testAnAccountWithHistoryToFetchIsStartedOnTheImportQueue(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 500);
$this->importer->ensureRunning($this->account);
$sent = $this->queue('ingest_backlog')->getSent();
self::assertCount(1, $sent);
self::assertInstanceOf(ImportMailMessage::class, $sent[0]->getMessage());
self::assertSame($this->account->id, $sent[0]->getMessage()->accountId);
self::assertSame([], $this->queue('ingest')->getSent(), 'history is never put on the queue new mail arrives on');
}
/**
* Every sync of the account calls ensureRunning(). While the import is
* moving, that must not put a second chain beside the first.
*/
public function testAnImportThatIsMovingIsNotStartedAgain(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 500);
$this->importer->ensureRunning($this->account);
$this->importer->ensureRunning($this->account);
$this->importer->ensureRunning($this->account);
self::assertCount(1, $this->queue('ingest_backlog')->getSent());
}
/** The safety net: a chain that broke is picked up once it has gone quiet. */
public function testAnImportThatHasGoneQuietIsStartedAgain(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 500);
$this->importer->ensureRunning($this->account);
$this->account->importBeatAt = new DateTimeImmutable(sprintf('-%d seconds', MailImporter::QUIET_SECONDS + 5));
$this->em->flush();
$this->importer->ensureRunning($this->account);
self::assertCount(2, $this->queue('ingest_backlog')->getSent());
}
/**
* A Microsoft account has no Mailbox rows to ask. Its import is the list
* of folders that have not been read to their end, and it used to have no
* import at all: its history travelled on the live queue.
*/
public function testAMicrosoftAccountWithFoldersStillToReadIsStartedToo(): void
{
$this->account->authType = AuthType::OAuth2->value;
$this->account->oauthProvider = 'microsoft';
$this->account->graphImport = [['folder' => 'AAMkAD-inbox', 'since' => 1_800_000_000, 'next' => null]];
$this->em->flush();
self::assertTrue($this->importer->hasWork($this->account));
$this->importer->ensureRunning($this->account);
self::assertCount(1, $this->queue('ingest_backlog')->getSent());
$this->account->graphImport = [];
$this->em->flush();
self::assertFalse($this->importer->hasWork($this->account), 'and is done when the list is empty');
}
public function testAnAccountWithNothingLeftToFetchIsLeftAlone(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 0);
$this->importer->ensureRunning($this->account);
self::assertFalse($this->importer->hasWork($this->account));
self::assertSame([], $this->queue('ingest_backlog')->getSent());
}
/**
* The order is the priority: the Inbox, what was written, a person's own
* folders, the archive, and last the two nobody is waiting for.
*/
public function testFoldersAreReadInTheOrderSomebodyWouldAskFor(): void
{
$this->folder('Trash', MailboxSpecialUse::TRASH, floor: 10);
$this->folder('Archive', MailboxSpecialUse::ARCHIVE, floor: 10);
$this->folder('Junk', MailboxSpecialUse::JUNK, floor: 10);
$this->folder('Projects', null, floor: 10);
$this->folder('Drafts', MailboxSpecialUse::DRAFTS, floor: 10);
$this->folder('Sent', MailboxSpecialUse::SENT, floor: 10);
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 10);
$this->folder('Already in', null, floor: 0);
$order = array_map(
static fn (Mailbox $mailbox): string => (string) $mailbox->fullPath,
static::getContainer()->get(MailboxRepository::class)->findImporting($this->account),
);
self::assertSame(['INBOX', 'Sent', 'Drafts', 'Projects', 'Archive', 'Junk', 'Trash'], $order);
}
/** Nothing fetches these, so nothing would ever finish them. */
public function testFoldersNobodySyncsAreNotWaitedFor(): void
{
$this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 0);
$off = $this->folder('Switched off', null, floor: null);
$off->isSyncEnabled = false;
$placeholder = $this->folder('[Gmail]', null, floor: null);
$placeholder->isSelectable = false;
$gone = $this->folder('Gone', null, floor: null);
$gone->missingSince = new DateTimeImmutable();
$this->em->flush();
self::assertFalse($this->importer->hasWork($this->account));
}
/**
* A folder gets its syncedAt at the end of its first sync, and that sync
* now ends at once with the history still to come. "The import is over"
* has to be read off the folder's own marker, or a mailbox's worth of old
* mail is treated as mail that has just arrived.
*/
public function testAFolderThatHasSyncedOnceIsStillImportingUntilItsHistoryIsIn(): void
{
$state = static::getContainer()->get(InitialImportState::class);
$inbox = $this->folder('INBOX', MailboxSpecialUse::INBOX, floor: 500);
$inbox->syncedAt = new DateTimeImmutable();
$this->em->flush();
self::assertFalse($state->isComplete($this->account));
$inbox->importFloorUid = 0;
$this->em->flush();
self::assertTrue($state->isComplete($this->account));
}
/**
* The poll, a push and the Sync button each queue a sync. One that began
* after a request was made, and went through, has already seen what the
* request was about.
*/
public function testASyncRequestIsRedundantOnlyOnceALaterSyncHasGoneThrough(): void
{
$requestedAt = new SyncAccountMessage((int) $this->account->id)->requestedAt;
self::assertNotNull($requestedAt, 'every request is stamped when it is made');
self::assertFalse($this->account->isSyncedSince($requestedAt), 'nothing has synced at all');
// A sync that was already running when the request was made.
$this->account->syncBeganAt = new DateTimeImmutable('@' . ($requestedAt - 30));
$this->account->recordSyncSuccess();
self::assertFalse($this->account->isSyncedSince($requestedAt), 'it may have read the server before the request');
// One that began afterwards, and has not finished.
$this->account->syncBeganAt = new DateTimeImmutable('@' . ($requestedAt + 5));
$this->account->lastSyncedAt = new DateTimeImmutable('@' . ($requestedAt - 10));
self::assertFalse($this->account->isSyncedSince($requestedAt), 'begun is not done');
// And finished.
$this->account->lastSyncedAt = new DateTimeImmutable('@' . ($requestedAt + 9));
self::assertTrue($this->account->isSyncedSince($requestedAt));
// Unless it failed.
$this->account->recordSyncFailure('the server went away');
self::assertFalse($this->account->isSyncedSince($requestedAt));
}
// ── Fixtures ──────────────────────────────────────────────────────────
private function folder(string $path, ?MailboxSpecialUse $use, ?int $floor): Mailbox
{
$mailbox = new Mailbox();
$mailbox->account = $this->account;
$mailbox->name = $path;
$mailbox->fullPath = $path;
$mailbox->specialUse = $use;
$mailbox->isSyncEnabled = true;
$mailbox->importFloorUid = $floor;
$this->em->persist($mailbox);
$this->em->flush();
return $mailbox;
}
private function queue(string $name): InMemoryTransport
{
return static::getContainer()->get(sprintf('messenger.transport.%s', $name));
}
}
@@ -19,7 +19,7 @@ final class BackgroundProcessesTest extends TestCase
public function testEveryProcessKeepsTheNameItsContainerHad(): void
{
self::assertSame(
['mercure', 'imap-supervisor', 'worker-export', 'worker-ingest', 'worker-live', 'worker-release', 'worker-enrich', 'worker-maintenance', 'worker-bulk', 'scheduler'],
['mercure', 'imap-supervisor', 'worker-export', 'worker-ingest', 'worker-backlog', 'worker-live', 'worker-release', 'worker-enrich', 'worker-maintenance', 'worker-bulk', 'scheduler'],
new BackgroundProcesses(self::IDENTIFIER)->names(),
);
}
+13
View File
@@ -17,6 +17,9 @@ account:
smtp:
heading: Ausgehend — SMTP
host: Server
password: Passwort zum Senden
same: Zum Senden dasselbe Passwort verwenden
separate: Zum Senden ein anderes Passwort verwenden
username: Benutzername
oauth:
calendar_declined: Das Konto ist verbunden, aber der Kalenderzugriff wurde nicht erteilt — seine Kalender werden also nicht synchronisiert. Verbinde es neu und hak auf Googles Zustimmungsseite den Kalenderzugriff an.
@@ -2150,7 +2153,17 @@ integration:
too_big: 'Zu groß zum Anhängen'
jobs:
button:
failed: "Etwas ist fehlgeschlagen"
importing: "Import läuft"
importing_percent: "Import · %percent% %"
working: "Arbeitet …"
heading: Hintergrundarbeit
import:
folder: "Gerade: %folder%"
heading: "%account% wird importiert"
newest_first: "Neueste Mail zuerst"
waiting: "Wartet darauf, weiterzumachen"
started: '%action% gestartet — das läuft im Hintergrund.'
failure:
abandoned: 'Unterbrochen — das ist auf halber Strecke stehen geblieben; was schon erledigt war, bleibt erledigt.'
+13
View File
@@ -17,6 +17,9 @@ account:
smtp:
heading: Outgoing — SMTP
host: Host
password: Sending password
same: Use the same password for sending
separate: Use a different password for sending
username: Username
oauth:
calendar_declined: The account is connected, but calendar access was not granted — so its calendars will not sync. Reconnect it and tick calendar access on Google's consent screen.
@@ -2149,7 +2152,17 @@ integration:
too_big: 'Too large to attach'
jobs:
button:
failed: "Something failed"
importing: "Importing"
importing_percent: "Importing · %percent%%"
working: "Working…"
heading: Background work
import:
folder: "Now: %folder%"
heading: "Importing %account%"
newest_first: "Newest mail first"
waiting: "Waiting to continue"
started: 'Started %action% — this runs in the background.'
failure:
abandoned: 'Interrupted — this stopped part-way, and whatever it had already done was kept.'
+13
View File
@@ -17,6 +17,9 @@ account:
smtp:
heading: Outgoin' — SMTP
host: Port o' call
password: "Password fer sendin'"
same: "Use th' same password fer sendin'"
separate: "Use a different password fer sendin'"
username: Name ye go by
oauth:
calendar_declined: The account be aboard, but ye never granted the almanac — so its calendars will not sync. Sign in again and tick the calendar box when Google asks.
@@ -2029,7 +2032,17 @@ integration:
too_big: 'Too heavy to haul'
jobs:
button:
failed: "Somethin' ran aground"
importing: "Haulin' aboard"
importing_percent: "Haulin' · %percent%%"
working: "Hard at work…"
heading: Work below decks
import:
folder: "Now: %folder%"
heading: "Haulin' %account% aboard"
newest_first: "Freshest post first"
waiting: "Waitin' fer the tide"
started: 'Started %action% — she runs below decks.'
failure:
abandoned: "Cut adrift — she stopped half way, and what were already done stays done."