package imap import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "fmt" "io" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Account represents an IMAP account configuration stored in the database. type Account struct { ID int64 `json:"id"` Owner string `json:"owner"` Name string `json:"name"` Host string `json:"host"` Port int `json:"port"` TLS string `json:"tls"` Username string `json:"username"` ExcludedFolders []string `json:"excluded_folders"` Status string `json:"status"` ErrorMsg string `json:"error_msg"` LastImportAt *time.Time `json:"last_import_at,omitempty"` LastImportCount int `json:"last_import_count"` ProgressCurrent int `json:"progress_current"` ProgressTotal int `json:"progress_total"` CreatedAt time.Time `json:"created_at"` } // Store manages IMAP account persistence in PostgreSQL. type Store struct { pool *pgxpool.Pool encKey [32]byte } const createTableSQL = ` CREATE TABLE IF NOT EXISTS imap_accounts ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 993, tls TEXT NOT NULL DEFAULT 'ssl', username TEXT NOT NULL, password_enc BYTEA NOT NULL, excluded_folders TEXT[] NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'idle', error_msg TEXT NOT NULL DEFAULT '', last_import_at TIMESTAMPTZ, last_import_count INTEGER NOT NULL DEFAULT 0, progress_current INTEGER NOT NULL DEFAULT 0, progress_total INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_imap_accounts_owner ON imap_accounts (owner); ` // 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) if err != nil { return nil, fmt.Errorf("imap store: connect: %w", err) } if _, err := pool.Exec(context.Background(), createTableSQL); err != nil { pool.Close() return nil, fmt.Errorf("imap store: migrate: %w", err) } key := sha256.Sum256([]byte(secret)) return &Store{pool: pool, encKey: key}, nil } // Close releases the database connection pool. func (s *Store) Close() { s.pool.Close() } // Create inserts a new IMAP account with an encrypted password. func (s *Store) Create(ctx context.Context, acc Account, password string) (*Account, error) { enc, err := encryptPassword(password, s.encKey) if err != nil { return nil, fmt.Errorf("imap store: encrypt password: %w", err) } row := s.pool.QueryRow(ctx, ` INSERT INTO imap_accounts (owner, name, host, port, tls, username, password_enc, excluded_folders) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at`, acc.Owner, acc.Name, acc.Host, acc.Port, acc.TLS, acc.Username, enc, acc.ExcludedFolders, ) if err := row.Scan(&acc.ID, &acc.CreatedAt); err != nil { return nil, fmt.Errorf("imap store: create: %w", err) } acc.Status = "idle" acc.ErrorMsg = "" return &acc, nil } // 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 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`) } 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) } if err != nil { return nil, fmt.Errorf("imap store: list: %w", err) } defer rows.Close() 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 { return nil, fmt.Errorf("imap store: scan: %w", err) } accounts = append(accounts, a) } return accounts, rows.Err() } // 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, ) if err != nil { return nil, fmt.Errorf("imap store: get %d: %w", id, err) } return &a, nil } // GetPassword retrieves and decrypts the stored password for an IMAP account. func (s *Store) GetPassword(ctx context.Context, id int64) (string, error) { var enc []byte err := s.pool.QueryRow(ctx, `SELECT password_enc FROM imap_accounts WHERE id = $1`, id).Scan(&enc) if err != nil { return "", fmt.Errorf("imap store: get password: %w", err) } return decryptPassword(enc, s.encKey) } // Delete removes an IMAP account by ID. func (s *Store) Delete(ctx context.Context, id int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM imap_accounts WHERE id = $1`, id) if err != nil { return fmt.Errorf("imap store: delete: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("imap store: account %d not found", id) } return nil } // UpdateExcluded sets the list of excluded folders for an account. func (s *Store) UpdateExcluded(ctx context.Context, id int64, excluded []string) error { _, err := s.pool.Exec(ctx, `UPDATE imap_accounts SET excluded_folders = $1 WHERE id = $2`, excluded, id) if err != nil { return fmt.Errorf("imap store: update excluded: %w", err) } return nil } // UpdateStatus updates the import progress and status of an account. func (s *Store) UpdateStatus(ctx context.Context, id int64, status, errMsg string, current, total int) error { _, err := s.pool.Exec(ctx, ` UPDATE imap_accounts SET status = $1, error_msg = $2, progress_current = $3, progress_total = $4 WHERE id = $5`, status, errMsg, current, total, id) if err != nil { return fmt.Errorf("imap store: update status: %w", err) } return nil } // UpdateDone marks an import as completed, setting status back to idle. func (s *Store) UpdateDone(ctx context.Context, id int64, count int) error { _, err := s.pool.Exec(ctx, ` UPDATE imap_accounts SET status = 'idle', error_msg = '', last_import_at = now(), last_import_count = $1, progress_current = 0, progress_total = 0 WHERE id = $2`, count, id) if err != nil { return fmt.Errorf("imap store: update done: %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[:]) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil } // decryptPassword decrypts a password previously encrypted with encryptPassword. func decryptPassword(ciphertext []byte, key [32]byte) (string, error) { block, err := aes.NewCipher(key[:]) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { return "", fmt.Errorf("ciphertext too short") } nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:] plaintext, err := gcm.Open(nil, nonce, ct, nil) if err != nil { return "", fmt.Errorf("decrypt failed: %w", err) } return string(plaintext), nil }