IMP-04: fehlerbehandlung-nicht-konformer-server

Defensive Fehlerbehandlung für nicht-RFC-konforme Mailserver beim
Import, mit dokumentierten Fallback-Pfaden statt Abbruch.

- client_real.go: resolveUIDValidity behandelt UIDVALIDITY=0 (bekannte
  archivmail-Abweichung, known-issues #5) und fehlende UIDVALIDITY-Angabe
  als definierten Fallback statt Sync-Abbruch — deterministisch aus dem
  Postfachnamen abgeleitet (FNV-1a), stabil bei wiederholten Läufen.
  parseFetchLines überspringt kaputte/unerwartete FETCH-Zeilen einzeln
  und protokolliert sie, statt den gesamten Lauf zu stoppen. Neuer
  Logger/WithLogger für nachvollziehbares Support-Logging.
- Echten Bug behoben: die getaggte Abschlusszeile enthält ebenfalls
  "FETCH " und wurde zunächst fälschlich als unerwartete Antwort
  geloggt — jetzt nur echte Untagged-Zeilen (Präfix "* ") betrachtet.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-04-PRUEFPROTOKOLL.md):
1. TestResolveUIDValidity_ZeroTriggersDefinedFallbackNotAbort: Server
   meldet real UIDVALIDITY=0, Sync liefert real Fallback statt Fehler.
2. TestParseFetchLines_UnexpectedResponseSkippedRestContinue: 2 kaputte
   Zeilen real übersprungen+protokolliert, übrige Nachrichten kommen an.
3. TestResolveUIDValidity_RegressionGuardAgainstZeroAbort: direkter
   Regressionsschutz gegen den ursprünglichen UIDVALIDITY-Bug.

Kein Umbau: imap/folderstate/scheduler.go 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:55:38 +02:00
co-authored by Claude Sonnet 5
parent e9947b1e28
commit 03c47d98f4
3 changed files with 352 additions and 34 deletions
+130 -34
View File
@@ -1,17 +1,43 @@
// IMP-04: defensive Fehlerbehandlung nicht-konformer Server. Bekannten
// Fehler vermeiden (siehe known-issues-archivmail.md #5): UIDVALIDITY=0
// führte in einer früheren Implementierung zu einem Resync-Abbruch —
// dieses Paket behandelt eine gemeldete UIDVALIDITY=0 als bekannte
// Serverabweichung mit definiertem Fallback (deterministisch aus dem
// Postfachnamen abgeleitet, siehe fallbackUIDValidity), NICHT als
// Fehlerabbruch. Unerwartete/kaputte Serverantworten (einzelne
// FETCH-Zeilen) werden übersprungen und protokolliert, statt den
// gesamten Abgleich zu stoppen (siehe parseFetchLines).
//
// Fallback-Verhalten für Support (Akzeptanzkriterium 3): jede erkannte
// Abweichung läuft über Logger — Standard-Logging-Ziel ist der
// Prozess-Log (log.Printf), bei Bedarf per WithLogger umleitbar/
// abschaltbar. Log-Präfix ist immer "imapimport: unerwartete
// server-antwort" bzw. "imapimport: UIDVALIDITY=0 gemeldet" für
// durchsuchbare Nachvollziehbarkeit.
package imapimport
import (
"bufio"
"context"
"fmt"
"hash/fnv"
"log"
"net"
"strconv"
"strings"
)
// Logger protokolliert erkannte Serverabweichungen (Akzeptanzkriterium
// 3: nachvollziehbar für Support). Signatur kompatibel mit log.Printf.
type Logger func(format string, args ...any)
func defaultLogger(format string, args ...any) {
log.Printf(format, args...)
}
// RealClient spricht echtes IMAP4rev1 (RFC 3501) über TCP — genutzt für
// den realistischen Testpostfach-Nachweis (Pflichtprüfung 3) gegen den
// echten ING-01-Server, und produktiv gegen jeden RFC-3501-konformen
// den realistischen Testpostfach-Nachweis (IMP-01 Pflichtprüfung 3) gegen
// den echten ING-01-Server, und produktiv gegen jeden RFC-3501-konformen
// IMAP-Server. Bewusst minimal: nur der für RunOnce nötige Ablauf
// (LOGIN, SELECT, UID FETCH ALL, LOGOUT), keine generische
// IMAP-Client-Bibliothek.
@@ -20,10 +46,24 @@ type RealClient struct {
username string
password string
dialer net.Dialer
logger Logger
}
func NewRealClient(addr, username, password string) *RealClient {
return &RealClient{addr: addr, username: username, password: password}
return &RealClient{addr: addr, username: username, password: password, logger: defaultLogger}
}
// WithLogger ersetzt das Standard-Logging-Ziel (z. B. für Tests, die die
// protokollierten Meldungen prüfen wollen, oder um es abzuschalten).
func (c *RealClient) WithLogger(logger Logger) *RealClient {
c.logger = logger
return c
}
func (c *RealClient) log(format string, args ...any) {
if c.logger != nil {
c.logger(format, args...)
}
}
func (c *RealClient) Sync(ctx context.Context, mailbox string) (uint64, []RemoteMessage, error) {
@@ -51,7 +91,7 @@ func (c *RealClient) Sync(ctx context.Context, mailbox string) (uint64, []Remote
if err != nil {
return 0, nil, fmt.Errorf("imapimport: select: %w", err)
}
uidvalidity, err := extractUIDValidity(selectLines)
uidvalidity, err := c.resolveUIDValidity(selectLines, mailbox)
if err != nil {
return 0, nil, err
}
@@ -60,7 +100,7 @@ func (c *RealClient) Sync(ctx context.Context, mailbox string) (uint64, []Remote
if err != nil {
return 0, nil, fmt.Errorf("imapimport: uid fetch: %w", err)
}
messages := parseFetchLines(fetchLines)
messages := c.parseFetchLines(fetchLines)
_, _ = sendCommand(conn, reader, 4, "LOGOUT")
@@ -99,7 +139,45 @@ func sendCommand(conn net.Conn, reader *bufio.Reader, tagN int, command string)
}
}
func extractUIDValidity(lines []string) (uint64, error) {
// resolveUIDValidity liest UIDVALIDITY aus der SELECT-Antwort
// (Akzeptanzkriterium 1). Eine gemeldete UIDVALIDITY=0 — bekannte
// Abweichung nicht-konformer Server (known-issues-archivmail.md #5) —
// löst einen definierten Fallback aus statt eines Abbruchs: ein
// deterministisch aus dem Postfachnamen abgeleiteter Ersatzwert, der bei
// wiederholten Läufen gegen denselben nicht-konformen Server STABIL
// bleibt (kein unnötiger Voll-Resync bei jedem einzelnen Lauf).
func (c *RealClient) resolveUIDValidity(lines []string, mailbox string) (uint64, error) {
v, found := extractUIDValidity(lines)
if !found {
c.log("imapimport: unerwartete server-antwort: keine UIDVALIDITY in SELECT-Antwort für %q gefunden, verwende fallback", mailbox)
return fallbackUIDValidity(mailbox), nil
}
if v == 0 {
c.log("imapimport: UIDVALIDITY=0 gemeldet für postfach %q (bekannte abweichung nicht-konformer server) — verwende definierten fallback statt sync-abbruch", mailbox)
return fallbackUIDValidity(mailbox), nil
}
return v, nil
}
// fallbackUIDValidity leitet einen deterministischen, garantiert von 0
// verschiedenen Ersatzwert aus dem Postfachnamen ab (FNV-1a, gleiche
// Technik wie mail/internal/search.DocumentID).
func fallbackUIDValidity(mailbox string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte("imap-fallback-uidvalidity:"))
_, _ = h.Write([]byte(mailbox))
v := h.Sum64()
if v == 0 {
v = 1
}
return v
}
// extractUIDValidity sucht "UIDVALIDITY <n>" in den SELECT-Antwortzeilen.
// found=false, wenn keine UIDVALIDITY-Angabe vorhanden ODER sie nicht als
// Zahl lesbar ist (beides bekannte Serverabweichungen, siehe
// resolveUIDValidity — kein Fehlerabbruch an dieser Stelle).
func extractUIDValidity(lines []string) (value uint64, found bool) {
for _, line := range lines {
idx := strings.Index(line, "UIDVALIDITY ")
if idx == -1 {
@@ -112,44 +190,62 @@ func extractUIDValidity(lines []string) (uint64, error) {
}
v, err := strconv.ParseUint(rest[:end], 10, 64)
if err != nil {
return 0, fmt.Errorf("imapimport: uidvalidity parsen: %w", err)
return 0, false
}
return v, nil
return v, true
}
return 0, fmt.Errorf("imapimport: keine UIDVALIDITY in SELECT-Antwort gefunden")
return 0, false
}
// parseFetchLines parst Zeilen der Form
// "* <seq> FETCH (UID <uid> FLAGS (<flags>))" (siehe mail/internal/imap
// writeFetchResults).
func parseFetchLines(lines []string) []RemoteMessage {
// writeFetchResults). Akzeptanzkriterium 2: eine einzelne unerwartete/
// kaputte Zeile wird protokolliert und übersprungen, alle übrigen,
// korrekt lesbaren Nachrichten werden trotzdem geliefert — der gesamte
// Lauf bricht dafür NICHT ab.
func (c *RealClient) parseFetchLines(lines []string) []RemoteMessage {
var messages []RemoteMessage
for _, line := range lines {
if !strings.Contains(line, "FETCH (UID ") {
if !strings.HasPrefix(line, "* ") {
continue // getaggte Abschlusszeile ("C3 OK ..."), kein Untagged-FETCH
}
if !strings.Contains(line, "FETCH ") {
continue // anderweitiges Untagged (z. B. künftig "* OK ..."), nichts zu parsen
}
msg, ok := parseSingleFetchLine(line)
if !ok {
c.log("imapimport: unerwartete server-antwort übersprungen: %q", line)
continue
}
uidIdx := strings.Index(line, "UID ") + len("UID ")
rest := line[uidIdx:]
spaceIdx := strings.IndexByte(rest, ' ')
if spaceIdx == -1 {
continue
}
uid, err := strconv.ParseUint(rest[:spaceIdx], 10, 32)
if err != nil {
continue
}
var flags []string
flagsStart := strings.Index(line, "FLAGS (")
flagsEnd := strings.LastIndex(line, ")")
if flagsStart != -1 && flagsEnd > flagsStart {
inner := line[flagsStart+len("FLAGS (") : flagsEnd]
if inner != "" {
flags = strings.Split(inner, " ")
}
}
messages = append(messages, RemoteMessage{UID: uint32(uid), Flags: flags})
messages = append(messages, msg)
}
return messages
}
func parseSingleFetchLine(line string) (RemoteMessage, bool) {
if !strings.Contains(line, "FETCH (UID ") {
return RemoteMessage{}, false
}
uidIdx := strings.Index(line, "UID ") + len("UID ")
rest := line[uidIdx:]
spaceIdx := strings.IndexByte(rest, ' ')
if spaceIdx == -1 {
return RemoteMessage{}, false
}
uid, err := strconv.ParseUint(rest[:spaceIdx], 10, 32)
if err != nil {
return RemoteMessage{}, false
}
var flags []string
flagsStart := strings.Index(line, "FLAGS (")
flagsEnd := strings.LastIndex(line, ")")
if flagsStart != -1 && flagsEnd > flagsStart {
inner := line[flagsStart+len("FLAGS (") : flagsEnd]
if inner != "" {
flags = strings.Split(inner, " ")
}
}
return RemoteMessage{UID: uint32(uid), Flags: flags}, true
}