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
+118 -28
View File
@@ -31,6 +31,15 @@ type Account struct {
ProgressCurrent int `json:"progress_current"`
ProgressTotal int `json:"progress_total"`
CreatedAt time.Time `json:"created_at"`
// PROJ-8: Auto-sync fields
SyncIntervalMin int `json:"sync_interval_min"`
LastSyncAt *time.Time `json:"last_sync_at,omitempty"`
LastSyncCount int `json:"last_sync_count"`
LastUID uint32 `json:"last_uid"`
SyncRunning bool `json:"sync_running"`
SyncStatus string `json:"sync_status"`
SyncErrorMsg string `json:"sync_error_msg"`
}
// Store manages IMAP account persistence in PostgreSQL.
@@ -62,6 +71,17 @@ CREATE TABLE IF NOT EXISTS imap_accounts (
CREATE INDEX IF NOT EXISTS idx_imap_accounts_owner ON imap_accounts (owner);
`
// migrationSQL adds the PROJ-8 sync columns if they do not yet exist.
const migrationSQL = `
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_interval_min INTEGER NOT NULL DEFAULT 0;
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS last_sync_at TIMESTAMPTZ;
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS last_sync_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS last_uid BIGINT NOT NULL DEFAULT 0;
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_running BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_status TEXT NOT NULL DEFAULT '';
ALTER TABLE imap_accounts ADD COLUMN IF NOT EXISTS sync_error_msg TEXT NOT NULL DEFAULT '';
`
// New creates a new Store, connects to PostgreSQL, and runs the migration.
func New(dsn, secret string) (*Store, error) {
pool, err := pgxpool.New(context.Background(), dsn)
@@ -71,7 +91,12 @@ func New(dsn, secret string) (*Store, error) {
if _, err := pool.Exec(context.Background(), createTableSQL); err != nil {
pool.Close()
return nil, fmt.Errorf("imap store: migrate: %w", err)
return nil, fmt.Errorf("imap store: migrate create: %w", err)
}
if _, err := pool.Exec(context.Background(), migrationSQL); err != nil {
pool.Close()
return nil, fmt.Errorf("imap store: migrate alter: %w", err)
}
key := sha256.Sum256([]byte(secret))
@@ -106,23 +131,43 @@ func (s *Store) Create(ctx context.Context, acc Account, password string) (*Acco
return &acc, nil
}
// selectColumns is the canonical column list used in all SELECT statements.
// Column order must match the Scan call in scanRow.
// Leading and trailing spaces are intentional for correct SQL concatenation.
const selectColumns = ` id, owner, name, host, port, tls, username, excluded_folders,
status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at,
sync_interval_min, last_sync_at, last_sync_count, last_uid,
sync_running, sync_status, sync_error_msg `
// scanner abstracts pgx.Row and pgx.Rows — both expose Scan(...any) error.
type scanner interface {
Scan(dest ...any) error
}
func scanRow(row scanner) (Account, error) {
var a Account
err := row.Scan(
&a.ID, &a.Owner, &a.Name, &a.Host, &a.Port, &a.TLS, &a.Username,
&a.ExcludedFolders, &a.Status, &a.ErrorMsg, &a.LastImportAt,
&a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt,
&a.SyncIntervalMin, &a.LastSyncAt, &a.LastSyncCount, &a.LastUID,
&a.SyncRunning, &a.SyncStatus, &a.SyncErrorMsg,
)
return a, err
}
// List returns IMAP accounts. Admins see all accounts; regular users see only their own.
func (s *Store) List(ctx context.Context, owner string, isAdmin bool) ([]Account, error) {
var rows pgx.Rows
var err error
q := `SELECT` + selectColumns + `FROM imap_accounts`
if isAdmin {
rows, err = s.pool.Query(ctx, `
SELECT id, owner, name, host, port, tls, username, excluded_folders,
status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at
FROM imap_accounts ORDER BY id`)
rows, err = s.pool.Query(ctx, q+` ORDER BY id`)
} else {
rows, err = s.pool.Query(ctx, `
SELECT id, owner, name, host, port, tls, username, excluded_folders,
status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at
FROM imap_accounts WHERE owner = $1 ORDER BY id`, owner)
rows, err = s.pool.Query(ctx, q+` WHERE owner = $1 ORDER BY id`, owner)
}
if err != nil {
return nil, fmt.Errorf("imap store: list: %w", err)
@@ -131,12 +176,28 @@ func (s *Store) List(ctx context.Context, owner string, isAdmin bool) ([]Account
var accounts []Account
for rows.Next() {
var a Account
if err := rows.Scan(
&a.ID, &a.Owner, &a.Name, &a.Host, &a.Port, &a.TLS, &a.Username,
&a.ExcludedFolders, &a.Status, &a.ErrorMsg, &a.LastImportAt,
&a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt,
); err != nil {
a, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("imap store: scan: %w", err)
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}
// ListAll returns all IMAP accounts regardless of owner — used by the scheduler.
func (s *Store) ListAll(ctx context.Context) ([]Account, error) {
rows, err := s.pool.Query(ctx,
`SELECT`+selectColumns+`FROM imap_accounts ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("imap store: list all: %w", err)
}
defer rows.Close()
var accounts []Account
for rows.Next() {
a, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("imap store: scan: %w", err)
}
accounts = append(accounts, a)
@@ -146,17 +207,9 @@ func (s *Store) List(ctx context.Context, owner string, isAdmin bool) ([]Account
// Get returns a single IMAP account by ID.
func (s *Store) Get(ctx context.Context, id int64) (*Account, error) {
var a Account
err := s.pool.QueryRow(ctx, `
SELECT id, owner, name, host, port, tls, username, excluded_folders,
status, error_msg, last_import_at, last_import_count,
progress_current, progress_total, created_at
FROM imap_accounts WHERE id = $1`, id,
).Scan(
&a.ID, &a.Owner, &a.Name, &a.Host, &a.Port, &a.TLS, &a.Username,
&a.ExcludedFolders, &a.Status, &a.ErrorMsg, &a.LastImportAt,
&a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt,
)
row := s.pool.QueryRow(ctx,
`SELECT`+selectColumns+`FROM imap_accounts WHERE id = $1`, id)
a, err := scanRow(row)
if err != nil {
return nil, fmt.Errorf("imap store: get %d: %w", id, err)
}
@@ -219,6 +272,43 @@ func (s *Store) UpdateDone(ctx context.Context, id int64, count int) error {
return nil
}
// UpdateSyncInterval sets the automatic sync interval for an account.
// intervalMin == 0 disables automatic sync.
func (s *Store) UpdateSyncInterval(ctx context.Context, id int64, intervalMin int) error {
_, err := s.pool.Exec(ctx,
`UPDATE imap_accounts SET sync_interval_min = $1 WHERE id = $2`,
intervalMin, id)
if err != nil {
return fmt.Errorf("imap store: update sync interval: %w", err)
}
return nil
}
// SetSyncRunning marks whether a background sync is currently active for an account.
func (s *Store) SetSyncRunning(ctx context.Context, id int64, running bool) error {
_, err := s.pool.Exec(ctx,
`UPDATE imap_accounts SET sync_running = $1 WHERE id = $2`,
running, id)
if err != nil {
return fmt.Errorf("imap store: set sync running: %w", err)
}
return nil
}
// UpdateSyncResult persists the outcome of a completed sync run.
func (s *Store) UpdateSyncResult(ctx context.Context, id int64, status, errMsg string, count int, lastUID uint32) error {
_, err := s.pool.Exec(ctx, `
UPDATE imap_accounts
SET sync_status = $1, sync_error_msg = $2, last_sync_count = $3,
last_uid = $4, last_sync_at = now(), sync_running = FALSE
WHERE id = $5`,
status, errMsg, count, lastUID, id)
if err != nil {
return fmt.Errorf("imap store: update sync result: %w", err)
}
return nil
}
// encryptPassword encrypts a plaintext password using AES-256-GCM.
func encryptPassword(plaintext string, key [32]byte) ([]byte, error) {
block, err := aes.NewCipher(key[:])