Files
nexarch/mail/internal/imapimport/client_real.go
T
sysopsandClaude Sonnet 5 e9947b1e28 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
2026-08-31 23:45:08 +02:00

156 lines
4.2 KiB
Go

package imapimport
import (
"bufio"
"context"
"fmt"
"net"
"strconv"
"strings"
)
// 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
// 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
}
func NewRealClient(addr, username, password string) *RealClient {
return &RealClient{addr: addr, username: username, password: password}
}
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 := extractUIDValidity(selectLines)
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 := 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
}
}
}
func extractUIDValidity(lines []string) (uint64, error) {
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, fmt.Errorf("imapimport: uidvalidity parsen: %w", err)
}
return v, nil
}
return 0, fmt.Errorf("imapimport: keine UIDVALIDITY in SELECT-Antwort gefunden")
}
// parseFetchLines parst Zeilen der Form
// "* <seq> FETCH (UID <uid> FLAGS (<flags>))" (siehe mail/internal/imap
// writeFetchResults).
func parseFetchLines(lines []string) []RemoteMessage {
var messages []RemoteMessage
for _, line := range lines {
if !strings.Contains(line, "FETCH (UID ") {
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})
}
return messages
}