Files
mailgosend/internal/db/users.go
T
2026-05-25 14:30:41 +00:00

188 lines
6.0 KiB
Go

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
}