// 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 " 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 // "* FETCH (UID 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 }