package db import ( "context" "database/sql" "fmt" "time" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" ) // GetUserByEmail returns the user with the given email (case-insensitive), or nil. func (d *DB) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { row := d.db.QueryRowContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE lower(email)=lower(?)`, email) return scanUser(row) } // GetUserByID returns the user with the given ID, or nil. func (d *DB) GetUserByID(ctx context.Context, id int64) (*models.User, error) { row := d.db.QueryRowContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE id=?`, id) return scanUser(row) } // UserExistsByEmail returns true if any user (enabled or not) has this email or alias. func (d *DB) UserExistsByEmail(ctx context.Context, email string) (bool, error) { var count int err := d.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM ( SELECT 1 FROM users WHERE lower(email)=lower(?) AND enabled=1 UNION ALL SELECT 1 FROM user_aliases WHERE lower(alias_email)=lower(?) )`, email, email).Scan(&count) return count > 0, err } // ResolveEmail returns the canonical user for an email or alias, or nil. func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, error) { // Direct match first. u, err := d.GetUserByEmail(ctx, email) if err != nil || u != nil { return u, err } // Alias match. var userID int64 err = d.db.QueryRowContext(ctx, "SELECT user_id FROM user_aliases WHERE lower(alias_email)=lower(?)", email). Scan(&userID) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return d.GetUserByID(ctx, userID) } // HasAdminUser returns true if at least one enabled admin user exists. func (d *DB) HasAdminUser(ctx context.Context) (bool, error) { var n int err := d.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE admin=1 AND enabled=1").Scan(&n) return n > 0, err } // CreateUser inserts a new user. Returns the new ID. func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) { res, err := d.db.ExecContext(ctx, ` INSERT INTO users (domain_id, username, email, password_hash, display_name, quota_bytes, enabled, admin, domain_admin, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`, domainID, username, email, passwordHash, displayName, quotaBytes, domainAdmin, time.Now().UTC()) if err != nil { return 0, fmt.Errorf("create user: %w", err) } return res.LastInsertId() } // CreateAdminUser inserts the first superadmin (admin=1). Used only during initial setup. func (d *DB) CreateAdminUser(ctx context.Context, domainID int64, email, passwordHash string) (int64, error) { username := email res, err := d.db.ExecContext(ctx, ` INSERT INTO users (domain_id, username, email, password_hash, display_name, quota_bytes, enabled, admin, domain_admin, created_at) VALUES (?, ?, ?, ?, ?, 0, 1, 1, 1, ?)`, domainID, username, email, passwordHash, email, time.Now().UTC()) if err != nil { return 0, fmt.Errorf("create admin user: %w", err) } return res.LastInsertId() } // UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store). func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error { _, err := d.db.ExecContext(ctx, "UPDATE users SET used_bytes = MAX(0, used_bytes + ?) WHERE id=?", delta, userID) return err } // UpdateLastLogin sets last_login to now. func (d *DB) UpdateLastLogin(ctx context.Context, userID int64) { d.db.ExecContext(ctx, //nolint:errcheck — best-effort "UPDATE users SET last_login=? WHERE id=?", time.Now().UTC(), userID) } // ListUsers returns all users for a domain. func (d *DB) ListUsers(ctx context.Context, domainID int64) ([]*models.User, error) { rows, err := d.db.QueryContext(ctx, ` SELECT id, domain_id, username, email, password_hash, display_name, quota_bytes, used_bytes, enabled, admin, domain_admin, is_relay, mfa_secret_enc, mfa_enabled, recovery_codes_enc, created_at, last_login FROM users WHERE domain_id=? ORDER BY email`, domainID) if err != nil { return nil, err } defer rows.Close() var users []*models.User for rows.Next() { var u models.User var mfaEnc, rcEnc []byte var lastLogin sql.NullTime err := rows.Scan( &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin, &u.IsRelay, &mfaEnc, &u.MFAEnabled, &rcEnc, &u.CreatedAt, &lastLogin, ) if err != nil { return nil, err } u.MFASecretEnc = mfaEnc u.RecoveryCodesEnc = rcEnc if lastLogin.Valid { u.LastLogin = lastLogin.Time } users = append(users, &u) } return users, rows.Err() } // SetUserIsRelay sets the is_relay flag for a user. func (d *DB) SetUserIsRelay(ctx context.Context, userID int64, isRelay bool) error { _, err := d.db.ExecContext(ctx, "UPDATE users SET is_relay=? WHERE id=?", isRelay, userID) return err } // ---- private ---- func scanUser(row *sql.Row) (*models.User, error) { var u models.User var mfaEnc, rcEnc []byte var lastLogin sql.NullTime err := row.Scan( &u.ID, &u.DomainID, &u.Username, &u.Email, &u.PasswordHash, &u.DisplayName, &u.QuotaBytes, &u.UsedBytes, &u.Enabled, &u.Admin, &u.DomainAdmin, &u.IsRelay, &mfaEnc, &u.MFAEnabled, &rcEnc, &u.CreatedAt, &lastLogin, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("scan user: %w", err) } u.MFASecretEnc = mfaEnc u.RecoveryCodesEnc = rcEnc if lastLogin.Valid { u.LastLogin = lastLogin.Time } return &u, nil }