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
252 lines
8.2 KiB
Go
252 lines
8.2 KiB
Go
// 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 (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.
|
|
type RealClient struct {
|
|
addr string
|
|
username string
|
|
password string
|
|
dialer net.Dialer
|
|
logger Logger
|
|
}
|
|
|
|
func NewRealClient(addr, username, password string) *RealClient {
|
|
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) {
|
|
conn, err := c.dialer.DialContext(ctx, "tcp", c.addr)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf("imapimport: verbindung aufbauen: %w", err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
_ = conn.SetDeadline(deadline)
|
|
}
|
|
|
|
reader := bufio.NewReader(conn)
|
|
// Begrüßung.
|
|
if _, err := readLine(reader); err != nil {
|
|
return 0, nil, fmt.Errorf("imapimport: begrüßung lesen: %w", err)
|
|
}
|
|
|
|
if _, err := sendCommand(conn, reader, 1, "LOGIN "+c.username+" "+c.password); err != nil {
|
|
return 0, nil, fmt.Errorf("imapimport: login: %w", err)
|
|
}
|
|
|
|
selectLines, err := sendCommand(conn, reader, 2, "SELECT "+mailbox)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf("imapimport: select: %w", err)
|
|
}
|
|
uidvalidity, err := c.resolveUIDValidity(selectLines, mailbox)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
fetchLines, err := sendCommand(conn, reader, 3, "UID FETCH 1:* (FLAGS)")
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf("imapimport: uid fetch: %w", err)
|
|
}
|
|
messages := c.parseFetchLines(fetchLines)
|
|
|
|
_, _ = sendCommand(conn, reader, 4, "LOGOUT")
|
|
|
|
return uidvalidity, messages, nil
|
|
}
|
|
|
|
func readLine(reader *bufio.Reader) (string, error) {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(line, "\r\n"), nil
|
|
}
|
|
|
|
// sendCommand sendet ein getaggtes Kommando und liest alle Zeilen bis
|
|
// zur getaggten Abschlusszeile (inklusive). Liefert einen Fehler, wenn
|
|
// die Abschlusszeile nicht "OK" meldet.
|
|
func sendCommand(conn net.Conn, reader *bufio.Reader, tagN int, command string) ([]string, error) {
|
|
tag := "C" + strconv.Itoa(tagN)
|
|
if _, err := conn.Write([]byte(tag + " " + command + "\r\n")); err != nil {
|
|
return nil, err
|
|
}
|
|
var lines []string
|
|
for {
|
|
line, err := readLine(reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lines = append(lines, line)
|
|
if strings.HasPrefix(line, tag+" ") {
|
|
if !strings.HasPrefix(line, tag+" OK") {
|
|
return lines, fmt.Errorf("server meldete: %s", line)
|
|
}
|
|
return lines, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
continue
|
|
}
|
|
rest := line[idx+len("UIDVALIDITY "):]
|
|
end := strings.IndexAny(rest, "] ")
|
|
if end == -1 {
|
|
end = len(rest)
|
|
}
|
|
v, err := strconv.ParseUint(rest[:end], 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// parseFetchLines parst Zeilen der Form
|
|
// "* <seq> FETCH (UID <uid> FLAGS (<flags>))" (siehe mail/internal/imap
|
|
// 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.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
|
|
}
|
|
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
|
|
}
|