package db import ( "database/sql" "errors" "strings" "time" "golang.org/x/crypto/bcrypt" ) func equalFold(a, b string) bool { return strings.EqualFold(a, b) } func domainPart(address string) string { i := strings.LastIndex(address, "@") if i < 0 { return "" } return strings.ToLower(address[i+1:]) } // bcryptCost is pinned to 12 to match Python's bcrypt.gensalt() default, since Go's // bcrypt.DefaultCost is 10 and would otherwise silently produce weaker hashes. const bcryptCost = 12 // HashPassword mirrors models.hash_password. func HashPassword(password string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { return "", err } return string(b), nil } // CheckPassword mirrors models.check_password. func CheckPassword(password, hash string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } // GetSenderByEmail mirrors models.get_sender_by_email: case-insensitive match against // the lower-cased stored email, active senders only. func (d *DB) GetSenderByEmail(email string) (*Sender, error) { row := d.QueryRow(`SELECT id, email, password_hash, domain_id, can_send_as_domain, is_active, created_at, store_message_content FROM esrv_senders WHERE lower(email) = lower(?) AND is_active = 1`, email) var s Sender var createdAt string if err := row.Scan(&s.ID, &s.Email, &s.PasswordHash, &s.DomainID, &s.CanSendAsDomain, &s.IsActive, &createdAt, &s.StoreMessageContent); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } s.CreatedAt, _ = parseTime(createdAt) return &s, nil } const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at, mfa_exempt` // scanDomain scans a row selected with domainColumns, in that order. func scanDomain(row *sql.Row) (*Domain, error) { var dom Domain var createdAt string var verifiedAt sql.NullString if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt, &dom.MFAExempt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } dom.CreatedAt, _ = parseTime(createdAt) if verifiedAt.Valid { t, _ := parseTime(verifiedAt.String) dom.VerifiedAt = &t } return &dom, nil } // GetDomainByName mirrors models.get_domain_by_name. func (d *DB) GetDomainByName(name string) (*Domain, error) { row := d.QueryRow(`SELECT `+domainColumns+` FROM esrv_domains WHERE lower(domain_name) = lower(?) AND is_active = 1`, name) return scanDomain(row) } // GetWhitelistedIP mirrors models.get_whitelisted_ip. domainName == "" means no domain // filter, matching the Python default parameter. func (d *DB) GetWhitelistedIP(ipAddress, domainName string) (*WhitelistedIP, error) { var row *sql.Row if domainName == "" { row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1`, ipAddress) } else { dom, err := d.GetDomainByName(domainName) if err != nil { return nil, err } if dom == nil { return nil, nil } row = d.QueryRow(`SELECT id, ip_address, domain_id, is_active, created_at, store_message_content FROM esrv_whitelisted_ips WHERE ip_address = ? AND is_active = 1 AND domain_id = ?`, ipAddress, dom.ID) } var w WhitelistedIP var createdAt string if err := row.Scan(&w.ID, &w.IPAddress, &w.DomainID, &w.IsActive, &createdAt, &w.StoreMessageContent); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err } w.CreatedAt, _ = parseTime(createdAt) return &w, nil } // CanSendForDomain mirrors WhitelistedIP.can_send_for_domain. Not called from the live // auth path (models.py's own equivalent isn't either) — kept for interface parity. func (w WhitelistedIP) CanSendForDomain(d *DB, domainName string) (bool, error) { if !w.IsActive { return false, nil } dom, err := d.GetDomainByName(domainName) if err != nil || dom == nil { return false, err } return w.DomainID == dom.ID, nil } // LogAuthAttempt mirrors models.log_auth_attempt. func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool, message string) error { _, err := d.Exec(`INSERT INTO esrv_auth_logs (auth_type, identifier, ip_address, success, message) VALUES (?, ?, ?, ?, ?)`, authType, identifier, ipAddress, success, message) return err } // CountRecentFailedAttempts counts failed esrv_auth_logs rows for one identifier // (independent of which IP each attempt came from — a distributed credential- // stuffing attempt against a single account should still trip this) within // authType and since the given cutoff, powering the per-account lockout in // internal/webui/login.go and webmail_login.go. func (d *DB) CountRecentFailedAttempts(authType, identifier string, since time.Time) (int, error) { var n int // created_at is populated by SQLite's own CURRENT_TIMESTAMP: a plain // "YYYY-MM-DD HH:MM:SS" UTC string, space-separated, no fractional seconds, no // offset. modernc.org/sqlite instead binds a Go time.Time query parameter as // RFC3339Nano with a zone offset (e.g. "2026-08-14T06:57:50.497566315+01:00") — // a live check confirmed this by inserting a time.Time into a real column and // reading the stored text back. That format is structurally different from // CURRENT_TIMESTAMP's own (different separator, precision, and offset), so a // plain text >= comparison between the two doesn't reflect chronological order at // all (confirmed: it silently matched zero rows). Two Go-bound time.Time values // compared against each other DO work correctly, since the driver formats both // identically — this only breaks when one side is a raw SQL CURRENT_TIMESTAMP // default and the other is a Go-bound parameter, which happens on THIS column but // nowhere else in this codebase (checked every other DATETIME comparison). // Formatting since into CURRENT_TIMESTAMP's exact layout makes both sides match. err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs WHERE auth_type = ? AND identifier = ? AND success = 0 AND created_at >= ?`, authType, identifier, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n) return n, err } func parseTime(s string) (time.Time, error) { for _, layout := range []string{"2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05", time.RFC3339} { if t, err := time.Parse(layout, s); err == nil { return t, nil } } return time.Time{}, errors.New("unparseable time: " + s) }