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 } 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) }