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
344 lines
9.9 KiB
Go
344 lines
9.9 KiB
Go
package imap
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeAuthenticator ist ein In-Memory-Authenticator für Tests — echte
|
|
// Anbindung an Benutzerverwaltung ist Sache späterer Kacheln (IMP-01 u. a.).
|
|
type fakeAuthenticator struct {
|
|
users map[string]string
|
|
}
|
|
|
|
func (f fakeAuthenticator) Authenticate(_ context.Context, username, password string) (bool, error) {
|
|
want, ok := f.users[username]
|
|
return ok && want == password, nil
|
|
}
|
|
|
|
// fakeMailboxStore ist ein In-Memory-Postfachspeicher für Tests.
|
|
type fakeMailboxStore struct {
|
|
mailboxes map[string][]Message
|
|
}
|
|
|
|
func (f fakeMailboxStore) Select(_ context.Context, mailboxName string) (int, uint64, bool, error) {
|
|
msgs, ok := f.mailboxes[mailboxName]
|
|
return len(msgs), 1, ok, nil
|
|
}
|
|
|
|
func (f fakeMailboxStore) Fetch(_ context.Context, mailboxName string, seqNumbers []uint32) ([]Message, error) {
|
|
msgs, ok := f.mailboxes[mailboxName]
|
|
if !ok {
|
|
return nil, errors.New("imap: postfach nicht gefunden")
|
|
}
|
|
wanted := make(map[uint32]bool, len(seqNumbers))
|
|
for _, n := range seqNumbers {
|
|
wanted[n] = true
|
|
}
|
|
var result []Message
|
|
for _, m := range msgs {
|
|
if wanted[m.SequenceNumber] {
|
|
result = append(result, m)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f fakeMailboxStore) FetchByUID(_ context.Context, mailboxName string, uids []uint32) ([]Message, error) {
|
|
msgs, ok := f.mailboxes[mailboxName]
|
|
if !ok {
|
|
return nil, errors.New("imap: postfach nicht gefunden")
|
|
}
|
|
wanted := make(map[uint32]bool, len(uids))
|
|
for _, u := range uids {
|
|
wanted[u] = true
|
|
}
|
|
var result []Message
|
|
for _, m := range msgs {
|
|
if wanted[m.UID] {
|
|
result = append(result, m)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func startTestServer(t *testing.T) (addr string, stop func()) {
|
|
t.Helper()
|
|
auth := fakeAuthenticator{users: map[string]string{"alice": "geheim123"}}
|
|
store := fakeMailboxStore{mailboxes: map[string][]Message{
|
|
"INBOX": {
|
|
{SequenceNumber: 1, UID: 101, Flags: []string{"\\Seen"}},
|
|
{SequenceNumber: 2, UID: 102, Flags: []string{}},
|
|
},
|
|
}}
|
|
srv := NewServer(auth, store)
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listener: %v", err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_ = srv.Serve(ctx, listener)
|
|
close(done)
|
|
}()
|
|
|
|
return listener.Addr().String(), func() {
|
|
cancel()
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// imapClient ist ein minimaler Testclient (Zeile senden, Antwort lesen)
|
|
// — bewusst kein voller IMAP-Parser, nur genug, um Server-Antworten zu
|
|
// prüfen.
|
|
type imapClient struct {
|
|
conn net.Conn
|
|
reader *bufio.Reader
|
|
tagN int
|
|
}
|
|
|
|
func dial(t *testing.T, addr string) *imapClient {
|
|
t.Helper()
|
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
c := &imapClient{conn: conn, reader: bufio.NewReader(conn)}
|
|
c.readLine(t) // Begrüßung
|
|
return c
|
|
}
|
|
|
|
func (c *imapClient) readLine(t *testing.T) string {
|
|
t.Helper()
|
|
_ = c.conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
|
line, err := c.reader.ReadString('\n')
|
|
if err != nil {
|
|
t.Fatalf("antwort lesen: %v", err)
|
|
}
|
|
return strings.TrimRight(line, "\r\n")
|
|
}
|
|
|
|
// sendTagged sendet ein Kommando mit neuem Tag und liest Zeilen, bis die
|
|
// getaggte Abschlusszeile kommt — liefert alle Zeilen (inkl. Abschluss).
|
|
func (c *imapClient) sendTagged(t *testing.T, command string) (tag string, lines []string) {
|
|
t.Helper()
|
|
c.tagN++
|
|
tag = "A" + strconv.Itoa(c.tagN)
|
|
_, err := c.conn.Write([]byte(tag + " " + command + "\r\n"))
|
|
if err != nil {
|
|
t.Fatalf("kommando senden: %v", err)
|
|
}
|
|
for {
|
|
line := c.readLine(t)
|
|
lines = append(lines, line)
|
|
if strings.HasPrefix(line, tag+" ") {
|
|
return tag, lines
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *imapClient) close() { _ = c.conn.Close() }
|
|
|
|
// TestSession_StateTransitionsAndForbiddenTransitions ist die geforderte
|
|
// Pflichtprüfung 2: automatisierter Test deckt alle drei
|
|
// Zustandsübergänge UND deren verbotene Übergänge ab.
|
|
func TestSession_StateTransitionsAndForbiddenTransitions(t *testing.T) {
|
|
addr, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
|
|
// Verbotener Übergang: SELECT/FETCH in NotAuthenticated.
|
|
_, lines := c.sendTagged(t, `SELECT INBOX`)
|
|
if !strings.Contains(lines[len(lines)-1], "BAD") {
|
|
t.Fatalf("erwartete BAD für SELECT in NotAuthenticated, habe: %v", lines)
|
|
}
|
|
_, lines = c.sendTagged(t, `FETCH 1 (FLAGS)`)
|
|
if !strings.Contains(lines[len(lines)-1], "BAD") {
|
|
t.Fatalf("erwartete BAD für FETCH in NotAuthenticated, habe: %v", lines)
|
|
}
|
|
|
|
// NotAuthenticated -> Authenticated via LOGIN.
|
|
_, lines = c.sendTagged(t, `LOGIN alice geheim123`)
|
|
if !strings.Contains(lines[len(lines)-1], "OK") {
|
|
t.Fatalf("erwartete OK für LOGIN, habe: %v", lines)
|
|
}
|
|
|
|
// Verbotener Übergang: erneutes LOGIN in Authenticated.
|
|
_, lines = c.sendTagged(t, `LOGIN alice geheim123`)
|
|
if !strings.Contains(lines[len(lines)-1], "BAD") {
|
|
t.Fatalf("erwartete BAD für LOGIN in Authenticated, habe: %v", lines)
|
|
}
|
|
// Verbotener Übergang: FETCH in Authenticated (noch nicht Selected).
|
|
_, lines = c.sendTagged(t, `FETCH 1 (FLAGS)`)
|
|
if !strings.Contains(lines[len(lines)-1], "BAD") {
|
|
t.Fatalf("erwartete BAD für FETCH in Authenticated, habe: %v", lines)
|
|
}
|
|
|
|
// Authenticated -> Selected via SELECT.
|
|
_, lines = c.sendTagged(t, `SELECT INBOX`)
|
|
if !strings.Contains(lines[len(lines)-1], "OK") {
|
|
t.Fatalf("erwartete OK für SELECT, habe: %v", lines)
|
|
}
|
|
|
|
// In Selected sind SELECT (erneut) und FETCH erlaubt.
|
|
_, lines = c.sendTagged(t, `FETCH 1:2 (FLAGS)`)
|
|
if !strings.Contains(lines[len(lines)-1], "OK") {
|
|
t.Fatalf("erwartete OK für FETCH in Selected, habe: %v", lines)
|
|
}
|
|
}
|
|
|
|
// TestCommands_AllBaseCommandsAnswered ist die geforderte
|
|
// Pflichtprüfung/AC2: Grundbefehle sind implementiert und beantwortet.
|
|
func TestCommands_AllBaseCommandsAnswered(t *testing.T) {
|
|
addr, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
|
|
_, lines := c.sendTagged(t, "CAPABILITY")
|
|
if !containsSubstring(lines, "IMAP4rev1") {
|
|
t.Fatalf("CAPABILITY: erwartete IMAP4rev1 in antwort, habe: %v", lines)
|
|
}
|
|
|
|
_, lines = c.sendTagged(t, "LOGIN alice geheim123")
|
|
if !strings.Contains(lines[len(lines)-1], "OK") {
|
|
t.Fatalf("LOGIN fehlgeschlagen: %v", lines)
|
|
}
|
|
|
|
_, lines = c.sendTagged(t, "SELECT INBOX")
|
|
if !containsSubstring(lines, "2 EXISTS") {
|
|
t.Fatalf("SELECT: erwartete '2 EXISTS', habe: %v", lines)
|
|
}
|
|
|
|
_, lines = c.sendTagged(t, "FETCH 1 (FLAGS)")
|
|
if !containsSubstring(lines, "FETCH (UID") {
|
|
t.Fatalf("FETCH: erwartete FLAGS-Antwort, habe: %v", lines)
|
|
}
|
|
|
|
tag, lines := c.sendTagged(t, "LOGOUT")
|
|
if !containsSubstring(lines, "BYE") {
|
|
t.Fatalf("LOGOUT: erwartete BYE, habe: %v", lines)
|
|
}
|
|
if !strings.HasPrefix(lines[len(lines)-1], tag+" OK") {
|
|
t.Fatalf("LOGOUT: erwartete getaggtes OK, habe: %v", lines)
|
|
}
|
|
}
|
|
|
|
// TestSession_InvalidCommandKeepsConnectionOpen ist die geforderte
|
|
// Pflichtprüfung/AC3: ungültige Kommandosequenzen werden mit korrektem
|
|
// Fehlercode abgelehnt, NICHT mit Verbindungsabbruch.
|
|
func TestSession_InvalidCommandKeepsConnectionOpen(t *testing.T) {
|
|
addr, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
|
|
_, lines := c.sendTagged(t, "FRIMBULATOR")
|
|
if !strings.Contains(lines[len(lines)-1], "BAD") {
|
|
t.Fatalf("erwartete BAD für unbekanntes kommando, habe: %v", lines)
|
|
}
|
|
|
|
// Verbindung muss danach real weiter funktionieren (kein Abbruch).
|
|
_, lines = c.sendTagged(t, "CAPABILITY")
|
|
if !strings.Contains(lines[len(lines)-1], "OK") {
|
|
t.Fatalf("erwartete funktionierende verbindung nach ungültigem kommando, habe: %v", lines)
|
|
}
|
|
}
|
|
|
|
// TestSession_MalformedLineDoesNotDisconnect deckt zusätzlich eine
|
|
// syntaktisch fehlerhafte Zeile (kein Tag/Kommando erkennbar) ab.
|
|
func TestSession_MalformedLineDoesNotDisconnect(t *testing.T) {
|
|
addr, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
|
|
_, err := c.conn.Write([]byte("\"unterminated\r\n"))
|
|
if err != nil {
|
|
t.Fatalf("senden: %v", err)
|
|
}
|
|
line := c.readLine(t)
|
|
if !strings.HasPrefix(line, "* BAD") {
|
|
t.Fatalf("erwartete '* BAD' für fehlerhafte zeile, habe: %q", line)
|
|
}
|
|
|
|
_, lines := c.sendTagged(t, "CAPABILITY")
|
|
if !strings.Contains(lines[len(lines)-1], "OK") {
|
|
t.Fatalf("erwartete funktionierende verbindung nach fehlerhafter zeile, habe: %v", lines)
|
|
}
|
|
}
|
|
|
|
func containsSubstring(lines []string, sub string) bool {
|
|
for _, l := range lines {
|
|
if strings.Contains(l, sub) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestServer_50ParallelSessionsNoLeak ist die geforderte Pflichtprüfung
|
|
// 3: Lasttest mit 50 parallelen Sessions ohne Ressourcenleck.
|
|
func TestServer_50ParallelSessionsNoLeak(t *testing.T) {
|
|
addr, stop := startTestServer(t)
|
|
defer stop()
|
|
|
|
const sessions = 50
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, sessions)
|
|
|
|
for i := 0; i < sessions; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
c := &imapClient{conn: conn, reader: bufio.NewReader(conn)}
|
|
c.readLine(t)
|
|
c.sendTagged(t, "LOGIN alice geheim123")
|
|
c.sendTagged(t, "SELECT INBOX")
|
|
c.sendTagged(t, "FETCH 1:2 (FLAGS)")
|
|
c.sendTagged(t, "LOGOUT")
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
for err := range errCh {
|
|
t.Errorf("parallele sitzung fehlgeschlagen: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCommands_UIDFetchReturnsUID belegt die für IMP-01 nötige
|
|
// UID-FETCH-Erweiterung: reale UID-basierte Abfrage über echtes TCP.
|
|
func TestCommands_UIDFetchReturnsUID(t *testing.T) {
|
|
addr, stop := startTestServer(t)
|
|
defer stop()
|
|
c := dial(t, addr)
|
|
defer c.close()
|
|
|
|
c.sendTagged(t, "LOGIN alice geheim123")
|
|
c.sendTagged(t, "SELECT INBOX")
|
|
|
|
_, lines := c.sendTagged(t, "UID FETCH 101:102 (FLAGS)")
|
|
if !containsSubstring(lines, "UID 101") || !containsSubstring(lines, "UID 102") {
|
|
t.Fatalf("erwartete beide UIDs in der antwort, habe: %v", lines)
|
|
}
|
|
if !strings.Contains(lines[len(lines)-1], "OK") {
|
|
t.Fatalf("erwartete OK-abschluss, habe: %v", lines)
|
|
}
|
|
}
|