501ee8f7ea
Gleiches Muster wie bei IMAP (730099d): domain_admin konnte POP3-Konten
fremder Tenants auflisten, löschen und Importe/Progress fremder Tenants
ansehen, da pop3_accounts keine tenant_id hatte und Store.List() für
Admins ungefiltert alle Konten lieferte.
- pop3_accounts: neue Spalte tenant_id (ALTER TABLE ADD COLUMN IF NOT EXISTS)
- Store.List() filtert nach tenant_id, außer für superadmin
- Store.Create() setzt tenant_id beim Anlegen
- delete/start-import/progress prüfen zusätzlich tenantAccessAllowed()
271 lines
8.6 KiB
Go
271 lines
8.6 KiB
Go
// 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"`
|
|
TenantID *int64 `json:"tenant_id,omitempty"`
|
|
}
|
|
|
|
// 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);
|
|
ALTER TABLE pop3_accounts ADD COLUMN IF NOT EXISTS tenant_id INTEGER REFERENCES tenants(id);
|
|
`
|
|
|
|
// 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, tenant_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, created_at`,
|
|
acc.Owner, acc.Name, acc.Host, acc.Port, acc.TLS, acc.TLSSkipVerify, acc.Username, enc, acc.TenantID,
|
|
)
|
|
|
|
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, tenant_id `
|
|
|
|
// 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, &a.TenantID,
|
|
)
|
|
return a, err
|
|
}
|
|
|
|
// List returns POP3 accounts. Superadmins (tenantID == nil) see all accounts;
|
|
// other admins (tenantID != nil) see all accounts within their own tenant;
|
|
// regular users see only their own accounts.
|
|
func (s *Store) List(ctx context.Context, owner string, isAdmin bool, tenantID *int64) ([]Account, error) {
|
|
var rows pgx.Rows
|
|
var err error
|
|
|
|
q := `SELECT` + selectColumns + `FROM pop3_accounts`
|
|
|
|
switch {
|
|
case isAdmin && tenantID == nil:
|
|
rows, err = s.pool.Query(ctx, q+` ORDER BY id`)
|
|
case isAdmin:
|
|
rows, err = s.pool.Query(ctx, q+` WHERE tenant_id = $1 ORDER BY id`, *tenantID)
|
|
default:
|
|
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
|
|
}
|