package imapimport import ( "context" "fmt" "sort" ) // Handler verarbeitet die vom Scheduler klassifizierten Nachrichten. // Echte Ablage/Indexierung ist Sache späterer Kacheln (IMP-02 u. a.) — // dieses Paket bereitet nur die Schnittstelle vor. type Handler interface { // OnNewMessage wird GENAU EINMAL je UID aufgerufen, die seit dem // letzten Abgleich neu hinzugekommen ist (Akzeptanzkriterium 1). // Ein Fehler bricht den aktuellen Lauf ab, OHNE den Fortschritt für // bereits erfolgreich verarbeitete Nachrichten zu verlieren // (Akzeptanzkriterium 3). OnNewMessage(ctx context.Context, tenantSlug, mailboxName string, msg RemoteMessage) error // OnExistingMessageState wird für bereits bekannte Nachrichten mit // ihrem AKTUELLEN Flag-Zustand aufgerufen (Akzeptanzkriterium 2: // Zustandsänderungen wie gelesen/gelöscht abgeglichen) — NIEMALS als // Neuimport, die Nachricht selbst wird nicht erneut abgelegt. OnExistingMessageState(ctx context.Context, tenantSlug, mailboxName string, msg RemoteMessage) error } // Scheduler führt den periodischen, UID-basierten Delta-Sync aus. type Scheduler struct { store *Store } func NewScheduler(store *Store) *Scheduler { return &Scheduler{store: store} } // SyncResult fasst einen abgeschlossenen Lauf zusammen. type SyncResult struct { NewMessages int ExistingMessages int Rebuilt bool } // RunOnce führt genau einen Abgleich für ein Postfach aus (Akzeptanz- // kriterium 1/2/3). Nachrichten werden nach UID aufsteigend verarbeitet; // der Fortschritt wird nach JEDER neuen Nachricht einzeln persistiert // (Store.advance), damit ein Absturz mitten im Lauf keine Nachricht // verliert und beim nächsten Lauf keine bereits verarbeitete Nachricht // erneut als "neu" gilt (Pflichtprüfung 1/2: kein Doppelimport, auch // nach simuliertem Neustart). func (s *Scheduler) RunOnce(ctx context.Context, tenantSlug, mailboxName string, client IMAPClient, handler Handler) (SyncResult, error) { state, err := s.store.GetOrCreate(ctx, tenantSlug, mailboxName) if err != nil { return SyncResult{}, err } uidvalidity, messages, err := client.Sync(ctx, mailboxName) if err != nil { return SyncResult{}, fmt.Errorf("imapimport: postfach abrufen: %w", err) } result := SyncResult{} lastSyncedUID := state.LastSyncedUID // Bekannten Fehler vermeiden (archivmail: UIDVALIDITY=0 bricht // Resync): jede Änderung der UIDVALIDITY gegenüber dem persistierten // Stand (0 = "noch nie synchronisiert", kein Rebuild) löst einen // vollständigen Resync aus — alle Nachrichten gelten wieder als neu. if state.LastUIDValidity != 0 && uidvalidity != state.LastUIDValidity { lastSyncedUID = 0 result.Rebuilt = true } sorted := make([]RemoteMessage, len(messages)) copy(sorted, messages) sort.Slice(sorted, func(i, j int) bool { return sorted[i].UID < sorted[j].UID }) for _, msg := range sorted { if msg.UID > lastSyncedUID { if err := handler.OnNewMessage(ctx, tenantSlug, mailboxName, msg); err != nil { return result, fmt.Errorf("imapimport: neue nachricht uid=%d verarbeiten: %w", msg.UID, err) } lastSyncedUID = msg.UID if err := s.store.advance(ctx, tenantSlug, mailboxName, uidvalidity, lastSyncedUID); err != nil { return result, err } result.NewMessages++ continue } if err := handler.OnExistingMessageState(ctx, tenantSlug, mailboxName, msg); err != nil { return result, fmt.Errorf("imapimport: zustand für uid=%d abgleichen: %w", msg.UID, err) } result.ExistingMessages++ } // Auch ohne neue Nachrichten muss eine geänderte UIDVALIDITY // persistiert werden (z. B. Rebuild bei leerem Postfach). if uidvalidity != state.LastUIDValidity { if err := s.store.advance(ctx, tenantSlug, mailboxName, uidvalidity, lastSyncedUID); err != nil { return result, err } } return result, nil }