feat(PROJ-8): Automatischer IMAP-Sync (Cron-Scheduler)

Backend:
- internal/imap/store.go: 7 neue Felder (sync_interval_min, last_sync_at,
  last_sync_count, last_uid, sync_running, sync_status, sync_error_msg)
  DB-Migration via ALTER TABLE ADD COLUMN IF NOT EXISTS
  Neue Methoden: ListAll, UpdateSyncInterval, SetSyncRunning, UpdateSyncResult
- internal/imap/scheduler.go: Scheduler mit time.Ticker (1 min),
  inkrementeller Sync via UID SEARCH UID <lastUID+1>:*,
  exponential backoff (3 Versuche: 1s / 60s / 300s),
  sync_running-Flag verhindert parallele Syncs
- internal/api/server.go: POST /api/imap/{id}/sync (manueller Trigger),
  PATCH /api/imap/{id} (sync_interval_min setzen, 0 oder 5-1440 min)
- cmd/archivmail/main.go: Scheduler gestartet + via SetImap verdrahtet

Frontend:
- src/lib/api.ts: 6 neue ImapAccount-Felder, triggerImapSync, updateImapInterval
- src/app/imap/page.tsx: Intervall-Dropdown, "Sync jetzt"-Button,
  Letzter-Sync-Anzeige mit Status-Badge, Polling auch bei sync_running

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 02:17:44 +01:00
parent 9cc540a880
commit 988c37d85d
9 changed files with 762 additions and 52 deletions
+92 -5
View File
@@ -41,9 +41,10 @@ type Server struct {
audlog *audit.Logger
logger *slog.Logger
mux *http.ServeMux
smtpDaemon *smtpd.Daemon
imapStore *imapstore.Store
imapImporter *imapstore.Importer
smtpDaemon *smtpd.Daemon
imapStore *imapstore.Store
imapImporter *imapstore.Importer
imapScheduler *imapstore.Scheduler
}
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
@@ -51,10 +52,11 @@ func (s *Server) SetSMTPDaemon(d *smtpd.Daemon) {
s.smtpDaemon = d
}
// SetImap wires the IMAP store and importer into the API server after construction.
func (s *Server) SetImap(store *imapstore.Store, importer *imapstore.Importer) {
// SetImap wires the IMAP store, importer, and scheduler into the API server after construction.
func (s *Server) SetImap(store *imapstore.Store, importer *imapstore.Importer, scheduler *imapstore.Scheduler) {
s.imapStore = store
s.imapImporter = importer
s.imapScheduler = scheduler
}
// New creates and wires up a new API server.
@@ -110,9 +112,11 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap))
s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap))
s.mux.HandleFunc("DELETE /api/imap/{id}", s.authMiddleware(s.handleDeleteImap))
s.mux.HandleFunc("PATCH /api/imap/{id}", s.authMiddleware(s.handleUpdateImapInterval))
s.mux.HandleFunc("POST /api/imap/test", s.authMiddleware(s.handleTestImap))
s.mux.HandleFunc("POST /api/imap/{id}/import", s.authMiddleware(s.handleStartImport))
s.mux.HandleFunc("GET /api/imap/{id}/progress", s.authMiddleware(s.handleImapProgress))
s.mux.HandleFunc("POST /api/imap/{id}/sync", s.authMiddleware(s.handleSyncNow))
}
// ServeHTTP implements http.Handler.
@@ -1205,6 +1209,89 @@ func (s *Server) handleImapProgress(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, acc)
}
// handleSyncNow triggers an immediate incremental sync for a single IMAP account.
func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil || s.imapScheduler == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin {
writeError(w, http.StatusForbidden, "access denied")
return
}
if err := s.imapScheduler.TriggerSync(r.Context(), id); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
// Return the account with the updated sync_running flag reflected immediately.
acc.SyncRunning = true
writeJSON(w, http.StatusOK, acc)
}
// handleUpdateImapInterval updates the automatic sync interval for an IMAP account.
func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
acc, err := s.imapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "account not found")
return
}
sess := sessionFromCtx(r.Context())
if acc.Owner != sess.Username && sess.Role != userstore.RoleAdmin {
writeError(w, http.StatusForbidden, "access denied")
return
}
var req struct {
SyncIntervalMin int `json:"sync_interval_min"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// 0 = disabled; otherwise must be between 5 and 1440 minutes.
if req.SyncIntervalMin != 0 && (req.SyncIntervalMin < 5 || req.SyncIntervalMin > 1440) {
writeError(w, http.StatusBadRequest, "sync_interval_min must be 0 (disabled) or between 5 and 1440")
return
}
if err := s.imapStore.UpdateSyncInterval(r.Context(), id, req.SyncIntervalMin); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update sync interval")
return
}
acc.SyncIntervalMin = req.SyncIntervalMin
writeJSON(w, http.StatusOK, acc)
}
// ── System stats handler ─────────────────────────────────────────────────
type diskStat struct {