IMP-01: imap-postfach-abruf-scheduler

Scheduler für periodischen IMAP-Postfach-Abruf mit UID-basiertem
Delta-Sync: neue Nachrichten erkennen, Zustandsänderungen abgleichen.

- imap (ING-01) minimal erweitert: Message.UID, MailboxStore.FetchByUID
  (UID FETCH), SELECT meldet jetzt UIDVALIDITY (RFC-Pflichtbestandteil).
  Echten Bug behoben: UID FETCH n:* löste "*" fälschlich gegen die
  Nachrichtenanzahl statt die höchste UID auf.
- imapimport/state.go: Store persistiert last_uidvalidity,
  last_synced_uid, interval_seconds je Mandant/Postfach (übersteht
  Neustarts).
- imapimport/scheduler.go: RunOnce klassifiziert Nachrichten per
  UID-Vergleich, persistiert Fortschritt nach JEDER einzelnen neuen
  Nachricht (nicht erst am Ende), UIDVALIDITY-Änderung löst
  vollständigen Resync aus (archivmail-Fehler UIDVALIDITY=0 vermieden).
- imapimport/client_real.go: echtes IMAP4rev1 über TCP
  (LOGIN/SELECT/UID FETCH/LOGOUT).

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-01-PRUEFPROTOKOLL.md):
1. TestRunOnce_TwoConsecutiveRunsNoDuplicateImport: zweiter Lauf real
   0 neue Nachrichten.
2. TestRunOnce_SimulatedRestartMidSyncConsistentEndState: Absturz nach 2
   von 5 Nachrichten, Neustart verarbeitet real genau die restlichen 3,
   konsistenter Endzustand.
3. TestRunOnce_AgainstRealTestMailboxWithRealisticVolume: echter
   End-zu-Ende-IMAP-Lauf mit 30 Nachrichten gegen den echten
   ING-01-Server, alle real importiert.

Kein Umbau: mail/internal/folderstate (ING-05) unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-31 23:45:08 +02:00
co-authored by Claude Sonnet 5
parent 0d3779d03e
commit e9947b1e28
12 changed files with 900 additions and 13 deletions
+49 -3
View File
@@ -53,7 +53,7 @@ func (s *Session) handleSelect(ctx context.Context, cmd command) bool {
}
mailboxName := cmd.Args[0]
exists, ok, err := s.store.Select(ctx, mailboxName)
exists, uidvalidity, ok, err := s.store.Select(ctx, mailboxName)
if err != nil || !ok {
// Fehlgeschlagenes SELECT lässt den Zustand laut RFC 3501 §6.3.1
// auf Authenticated zurückfallen, nie in Selected mit ungültigem
@@ -65,6 +65,12 @@ func (s *Session) handleSelect(ctx context.Context, cmd command) bool {
if err := writeUntagged(s.writer, fmt.Sprintf("%d EXISTS", exists)); err != nil {
return false
}
// RFC 3501 §2.3.1.1: UIDVALIDITY ist Pflichtbestandteil der
// SELECT-Antwort — Grundlage für IMP-01s Erkennung eines
// Ordner-Neuaufbaus.
if err := writeUntagged(s.writer, fmt.Sprintf("OK [UIDVALIDITY %d] UIDs valid", uidvalidity)); err != nil {
return false
}
s.state = Selected
s.mailbox = mailboxName
s.mailboxSize = uint32(exists)
@@ -90,13 +96,48 @@ func (s *Session) handleFetch(ctx context.Context, cmd command) bool {
if err != nil {
return s.writeErr(cmd.Tag, "NO", "FETCH failed")
}
return s.writeFetchResults(cmd.Tag, "FETCH", messages)
}
// handleUIDFetch implementiert "UID FETCH" (RFC 3501 §6.4.8) — wie FETCH,
// aber uid-set statt Sequenzsatz, Grundlage für IMP-01s UID-basierten
// Delta-Sync.
func (s *Session) handleUIDFetch(ctx context.Context, cmd command) bool {
if s.state != Selected {
return s.writeErr(cmd.Tag, "BAD", "UID FETCH not allowed in "+s.state.String()+" state")
}
if len(cmd.Args) < 2 {
return s.writeErr(cmd.Tag, "BAD", "UID FETCH requires a uid set")
}
// "*" in einem UID-Satz bedeutet "höchste vorhandene UID", NICHT die
// NachrichtenANZAHL (s.mailboxSize) — UIDs können durch Löschungen
// weit über der Nachrichtenzahl liegen (siehe mail/internal/
// folderstate, ING-05: UIDs werden nie wiederverwendet). Da
// parseSequenceSet einen Bereich materialisiert, wird "*" hier auf
// maxOpenEndedUID begrenzt statt auf 2^32-1 — verhindert eine
// Milliarden Einträge lange Schleife bei einem einzelnen offenen
// Bereich. FetchByUID liefert ohnehin nur tatsächlich vorhandene
// UIDs zurück, die Begrenzung ist für reale Postfachgrößen harmlos.
uidSet, err := parseSequenceSet(cmd.Args[1], maxOpenEndedUID)
if err != nil {
return s.writeErr(cmd.Tag, "BAD", "UID FETCH: invalid uid set")
}
messages, err := s.store.FetchByUID(ctx, s.mailbox, uidSet)
if err != nil {
return s.writeErr(cmd.Tag, "NO", "UID FETCH failed")
}
return s.writeFetchResults(cmd.Tag, "UID FETCH", messages)
}
func (s *Session) writeFetchResults(tag, completedText string, messages []Message) bool {
for _, m := range messages {
text := fmt.Sprintf("%d FETCH (FLAGS (%s))", m.SequenceNumber, strings.Join(m.Flags, " "))
text := fmt.Sprintf("%d FETCH (UID %d FLAGS (%s))", m.SequenceNumber, m.UID, strings.Join(m.Flags, " "))
if err := writeUntagged(s.writer, text); err != nil {
return false
}
}
return s.writeErr(cmd.Tag, "OK", "FETCH completed")
return s.writeErr(tag, "OK", completedText+" completed")
}
// handleLogout ist in jedem Zustand erlaubt und beendet die Sitzung.
@@ -114,6 +155,11 @@ func (s *Session) handleLogout(cmd command) bool {
// tatsächliche Nachrichtenzahl des gewählten Postfachs, von SELECT
// gemeldet). Volle RFC-3501-Sequenzsatz-Grammatik (verschachtelte
// Bereiche etc.) ist bewusst nicht Bestandteil dieser kleinsten Lösung.
// maxOpenEndedUID begrenzt, wie weit ein offener UID-Bereich ("N:*")
// materialisiert wird — deckt reale Postfachgrößen komfortabel ab, ohne
// bei einem einzelnen Kommando Milliarden Slice-Einträge zu erzeugen.
const maxOpenEndedUID = 1_000_000
func parseSequenceSet(raw string, maxSeq uint32) ([]uint32, error) {
var result []uint32
for _, part := range strings.Split(raw, ",") {