// Package pop3 implements POP3 account management and import. // It provides a DB-backed store for POP3 account configurations, // a raw TCP/TLS POP3 client, and an import worker. package pop3 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 a POP3 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"` TLSSkipVerify bool `json:"tls_skip_verify"` Username string `json:"username"` 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 POP3 account persistence in PostgreSQL. type Store struct { pool *pgxpool.Pool encKey [32]byte } const createTableSQL = ` CREATE TABLE IF NOT EXISTS pop3_accounts ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 110, tls TEXT NOT NULL DEFAULT 'none', tls_skip_verify BOOLEAN NOT NULL DEFAULT false, username TEXT NOT NULL, password_enc BYTEA NOT NULL, 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_pop3_accounts_owner ON pop3_accounts (owner); ` // New creates a new Store, connects to PostgreSQL, and runs the schema migration. func New(dsn, secret string) (*Store, error) { pool, err := pgxpool.New(context.Background(), dsn) if err != nil { return nil, fmt.Errorf("pop3 store: connect: %w", err) } if _, err := pool.Exec(context.Background(), createTableSQL); err != nil { pool.Close() return nil, fmt.Errorf("pop3 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 POP3 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("pop3 store: encrypt password: %w", err) } row := s.pool.QueryRow(ctx, ` INSERT INTO pop3_accounts (owner, name, host, port, tls, tls_skip_verify, username, password_enc) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at`, acc.Owner, acc.Name, acc.Host, acc.Port, acc.TLS, acc.TLSSkipVerify, acc.Username, enc, ) if err := row.Scan(&acc.ID, &acc.CreatedAt); err != nil { return nil, fmt.Errorf("pop3 store: create: %w", err) } acc.Status = "idle" acc.ErrorMsg = "" return &acc, nil } // selectColumns is the canonical column list used in all SELECT statements. const selectColumns = ` id, owner, name, host, port, tls, tls_skip_verify, username, status, error_msg, last_import_at, last_import_count, progress_current, progress_total, created_at ` // 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.TLSSkipVerify, &a.Username, &a.Status, &a.ErrorMsg, &a.LastImportAt, &a.LastImportCount, &a.ProgressCurrent, &a.ProgressTotal, &a.CreatedAt, ) return a, err } // List returns POP3 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 pop3_accounts` if isAdmin { rows, err = s.pool.Query(ctx, q+` ORDER BY id`) } else { rows, err = s.pool.Query(ctx, q+` WHERE owner = $1 ORDER BY id`, owner) } if err != nil { return nil, fmt.Errorf("pop3 store: list: %w", err) } defer rows.Close() var accounts []Account for rows.Next() { a, err := scanRow(rows) if err != nil { return nil, fmt.Errorf("pop3 store: scan: %w", err) } accounts = append(accounts, a) } return accounts, rows.Err() } // Get returns a single POP3 account by ID. func (s *Store) Get(ctx context.Context, id int64) (*Account, error) { row := s.pool.QueryRow(ctx, `SELECT`+selectColumns+`FROM pop3_accounts WHERE id = $1`, id) a, err := scanRow(row) if err != nil { return nil, fmt.Errorf("pop3 store: get %d: %w", id, err) } return &a, nil } // GetPassword retrieves and decrypts the stored password for a POP3 account. func (s *Store) GetPassword(ctx context.Context, id int64) (string, error) { var enc []byte err := s.pool.QueryRow(ctx, `SELECT password_enc FROM pop3_accounts WHERE id = $1`, id).Scan(&enc) if err != nil { return "", fmt.Errorf("pop3 store: get password: %w", err) } return decryptPassword(enc, s.encKey) } // Delete removes a POP3 account by ID. func (s *Store) Delete(ctx context.Context, id int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM pop3_accounts WHERE id = $1`, id) if err != nil { return fmt.Errorf("pop3 store: delete: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("pop3 store: account %d not found", id) } return nil } // DeleteByOwner removes all POP3 accounts belonging to the given username. func (s *Store) DeleteByOwner(ctx context.Context, username string) (int, error) { tag, err := s.pool.Exec(ctx, `DELETE FROM pop3_accounts WHERE owner = $1`, username) if err != nil { return 0, fmt.Errorf("pop3 store: delete by owner: %w", err) } return int(tag.RowsAffected()), 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 pop3_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("pop3 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 pop3_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("pop3 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 }