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, catchall_mailbox_id, send_rate_limit_per_hour, mta_sts_mode, caldav_enabled, carddav_enabled, dkim_dns_automation, use_global_dkim` // 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 var catchall sql.NullInt64 var rateLimit sql.NullInt64 if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt, &dom.MFAExempt, &catchall, &rateLimit, &dom.MTASTSMode, &dom.CalDAVEnabled, &dom.CardDAVEnabled, &dom.DKIMDNSAutomation, &dom.UseGlobalDKIM); 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 } if catchall.Valid { dom.CatchallMailboxID = &catchall.Int64 } if rateLimit.Valid { n := int(rateLimit.Int64) dom.SendRateLimitPerHour = &n } 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 } // authLogDedupWindow bounds how often one identifier+IP's successful logins write a // fresh esrv_auth_logs row (see LogAuthAttempt below) — 15 minutes, matching the // ballpark of this codebase's other fixed session-ish windows (IMAP's own // IdleTimeoutListener is 30 minutes) without needing to be configurable. const authLogDedupWindow = 15 * time.Minute // LogAuthAttempt mirrors models.log_auth_attempt. // // Successful attempts are coalesced: a repeated success with the exact same // authType+identifier+IP+message as one already logged within authLogDedupWindow only // refreshes an in-memory last-seen time (d.authLogDedup) instead of writing another // row. An IMAP or SMTP client that reconnects and re-authenticates every few // seconds/minutes instead of holding one long-lived connection (common — plenty of // desktop and mobile mail clients poll this way) would otherwise write one // esrv_auth_logs row per reconnect, burying real signal under thousands of identical // "successful login" entries. message is part of the key (not just // authType+identifier+IP) so this never collapses two events that happen to share a // type/identifier/IP but represent genuinely different things — this function also // backs one-off audit entries like "Passkey added: " / "Passkey removed" / // "MFA reset by admin " for the same admin in webui, which must never be silently // dropped just because they landed in the same 15-minute window. Failed attempts // always write a fresh row regardless: CountRecentFailedAttempts' per-account lockout // counts every one of those. func (d *DB) LogAuthAttempt(authType, identifier, ipAddress string, success bool, message string) error { if success { key := authType + "|" + identifier + "|" + ipAddress + "|" + message now := time.Now() d.authLogDedupMu.Lock() last, seen := d.authLogDedup[key] d.authLogDedup[key] = now d.authLogDedupMu.Unlock() if seen && now.Sub(last) < authLogDedupWindow { return nil } } _, 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) }